├── supabase ├── seed.sql └── config.toml ├── .eslintrc.json ├── bun.lockb ├── src ├── app │ ├── favicon.ico │ ├── api │ │ ├── uploadthing │ │ │ ├── route.ts │ │ │ └── core.ts │ │ ├── livekit │ │ │ └── route.ts │ │ ├── messages │ │ │ └── route.ts │ │ └── [direct-messages] │ │ │ └── route.ts │ ├── (main) │ │ ├── page.tsx │ │ ├── layout.tsx │ │ └── workspace │ │ │ └── [workspaceId] │ │ │ ├── direct-message │ │ │ └── [chatId] │ │ │ │ └── page.tsx │ │ │ ├── channels │ │ │ └── [channelId] │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ ├── create-workspace │ │ ├── [invite] │ │ │ └── page.ts │ │ └── page.tsx │ ├── layout.tsx │ └── (authentication) │ │ └── auth │ │ ├── callback │ │ └── route.ts │ │ ├── confirm │ │ └── route.ts │ │ └── page.tsx ├── lib │ ├── utils.ts │ └── uploadthing.ts ├── supabase │ ├── supabaseClient.ts │ ├── supabaseServer.ts │ └── supabaseSeverPages.ts ├── providers │ ├── theme-provider.tsx │ ├── query-provider.tsx │ ├── web-socket.tsx │ └── color-prefrences.tsx ├── components │ ├── ui │ │ ├── collapsible.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── progress.tsx │ │ ├── input.tsx │ │ ├── sonner.tsx │ │ ├── typography.tsx │ │ ├── tooltip.tsx │ │ ├── popover.tsx │ │ ├── avatar.tsx │ │ ├── scroll-area.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ └── form.tsx │ ├── progress-bar.tsx │ ├── dot-animated-loader.tsx │ ├── main-content.tsx │ ├── intro-banner.tsx │ ├── image-upload.tsx │ ├── no-data-component.tsx │ ├── video-chat.tsx │ ├── chat-header.tsx │ ├── create-channel-dialog.tsx │ ├── menu-bar.tsx │ ├── chat-messages.tsx │ ├── create-workspace.tsx │ ├── chat-group.tsx │ ├── text-editor.tsx │ ├── preferences-dialog.tsx │ ├── search-bar.tsx │ ├── chat-file-upload.tsx │ ├── info-section.tsx │ ├── sidebar-nav.tsx │ ├── sidebar.tsx │ └── chat-item.tsx ├── actions │ ├── register-with-email.ts │ ├── update-user-workspace.ts │ ├── add-member-to-workspace.ts │ ├── get-user-workspace-channels.ts │ ├── get-user-data.ts │ ├── create-workspace.ts │ ├── workspaces.ts │ └── channels.ts ├── hooks │ ├── create-workspace-values.ts │ ├── use-chat-scroll-handler.ts │ ├── use-chat-file.ts │ ├── use-chat-fetcher.ts │ └── use-chat-socket-connection.ts ├── pages │ └── api │ │ └── web-socket │ │ ├── io.ts │ │ ├── direct-messages │ │ ├── index.ts │ │ └── [messageId].ts │ │ └── messages │ │ ├── index.ts │ │ └── [messageId].ts ├── types │ └── app.ts ├── styles │ └── globals.css └── middleware.ts ├── postcss.config.mjs ├── components.json ├── .env.example ├── docs.txt ├── .gitignore ├── next.config.mjs ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── package.json ├── tailwind.config.ts ├── README.md └── sql.txt /supabase/seed.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laribright/slack-clone/HEAD/bun.lockb -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laribright/slack-clone/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/supabase/supabaseClient.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from '@supabase/ssr'; 2 | 3 | export const supabaseBrowserClient = createBrowserClient( 4 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 5 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 6 | ); 7 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from 'uploadthing/next'; 2 | 3 | import { ourFileRouter } from './core'; 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createRouteHandler({ 7 | router: ourFileRouter, 8 | }); 9 | -------------------------------------------------------------------------------- /src/lib/uploadthing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateUploadButton, 3 | generateUploadDropzone, 4 | } from '@uploadthing/react'; 5 | 6 | import { OurFileRouter } from '@/app/api/uploadthing/core'; 7 | 8 | export const UploadButton = generateUploadButton(); 9 | export const UploadDropzone = generateUploadDropzone(); 10 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /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": "src/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL=your-supabase-url 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-key 3 | NEXT_PUBLIC_CURRENT_ORIGIN=http://localhost:3000 4 | UPLOADTHING_SECRET=sk_live_your-uploadthing-secret 5 | UPLOADTHING_APP_ID=your-uploadthing-app-id 6 | NEXT_PUBLIC_SITE_URL=http://localhost:3000 7 | LIVEKIT_API_KEY=your-livekit-api-key 8 | LIVEKIT_API_SECRET=your-livekit-api-secret 9 | NEXT_PUBLIC_LIVEKIT_URL=wss://your-livekit-url.livekit.cloud -------------------------------------------------------------------------------- /src/providers/query-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | import React, { 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 | -------------------------------------------------------------------------------- /src/actions/register-with-email.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 4 | 5 | export async function registerWithEmail({ email }: { email: string }) { 6 | const supabase = await supabaseServerClient(); 7 | 8 | const response = await supabase.auth.signInWithOtp({ 9 | email, 10 | options: { 11 | emailRedirectTo: process.env.NEXT_PUBLIC_CURRENT_ORIGIN, 12 | }, 13 | }); 14 | 15 | return JSON.stringify(response); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(main)/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { getUserData } from '@/actions/get-user-data'; 4 | 5 | export default async function Home() { 6 | const userData = await getUserData(); 7 | 8 | if (!userData) return redirect('/auth'); 9 | 10 | const userWorkspaceId = userData.workspaces?.[0]; 11 | 12 | if (!userWorkspaceId) return redirect('/create-workspace'); 13 | 14 | if (userWorkspaceId) return redirect(`/workspace/${userWorkspaceId}`); 15 | } 16 | -------------------------------------------------------------------------------- /docs.txt: -------------------------------------------------------------------------------- 1 | SUPABASE SSR 2 | https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=environment&environment=client-component 3 | 4 | SUPABASE OAUTH 5 | https://supabase.com/docs/guides/auth/server-side/oauth-with-pkce-flow-for-ssr 6 | 7 | SUPABASE TYPES 8 | https://supabase.com/docs/guides/api/rest/generating-types 9 | 10 | UPLOADTHING 11 | https://uploadthing.com/ 12 | https://docs.uploadthing.com/ 13 | 14 | NEXTJS-IMAGE_CONFIG 15 | https://nextjs.org/docs/messages/next-image-unconfigured-host -------------------------------------------------------------------------------- /.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 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/components/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { Progress } from './ui/progress'; 5 | 6 | const ProgressBar = () => { 7 | const [progress, setProgress] = useState(0); 8 | 9 | useEffect(() => { 10 | const interval = setInterval(() => { 11 | setProgress(prevProgress => (prevProgress + 1) % 101); 12 | }, 100); 13 | 14 | return () => clearInterval(interval); 15 | }, []); 16 | 17 | return ; 18 | }; 19 | 20 | export default ProgressBar; 21 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'utfs.io', 8 | }, 9 | { 10 | protocol: 'https', 11 | hostname: 'cdn.pixabay.com', 12 | }, 13 | { 14 | protocol: 'https', 15 | hostname: 'hnfetimxhezkhdgcdshw.supabase.co', 16 | }, 17 | { 18 | protocol: 'https', 19 | hostname: 'lh3.googleusercontent.com', 20 | }, 21 | ], 22 | }, 23 | }; 24 | 25 | export default nextConfig; 26 | -------------------------------------------------------------------------------- /src/actions/update-user-workspace.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 4 | 5 | export const updateUserWorkspace = async ( 6 | userId: string, 7 | workspaceId: string 8 | ) => { 9 | const supabase = await supabaseServerClient(); 10 | 11 | // Update the user record 12 | const { data: updateWorkspaceData, error: updateWorkspaceError } = 13 | await supabase.rpc('add_workspace_to_user', { 14 | user_id: userId, 15 | new_workspace: workspaceId, 16 | }); 17 | 18 | return [updateWorkspaceData, updateWorkspaceError]; 19 | }; 20 | -------------------------------------------------------------------------------- /src/actions/add-member-to-workspace.ts: -------------------------------------------------------------------------------- 1 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 2 | 3 | export const addMemberToWorkspace = async ( 4 | userId: string, 5 | workspaceId: number 6 | ) => { 7 | const supabase = await supabaseServerClient(); 8 | 9 | // Update the workspace members 10 | const { data: addMemberToWorkspaceData, error: addMemberToWorkspaceError } = 11 | await supabase.rpc('add_member_to_workspace', { 12 | user_id: userId, 13 | workspace_id: workspaceId, 14 | }); 15 | 16 | return [addMemberToWorkspaceData, addMemberToWorkspaceError]; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/dot-animated-loader.tsx: -------------------------------------------------------------------------------- 1 | const DotAnimatedLoader = () => { 2 | return ( 3 |
4 | Loading... 5 |
6 |
7 |
8 |
9 | ); 10 | }; 11 | 12 | export default DotAnimatedLoader; 13 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { createUploadthing, type FileRouter } from 'uploadthing/next'; 2 | 3 | import { getUserData } from '@/actions/get-user-data'; 4 | 5 | const f = createUploadthing(); 6 | 7 | const currUser = async () => { 8 | const user = await getUserData(); 9 | return { userId: user?.id }; 10 | }; 11 | 12 | export const ourFileRouter = { 13 | workspaceImage: f({ 14 | image: { maxFileSize: '4MB', maxFileCount: 1 }, 15 | }) 16 | .middleware(() => currUser()) 17 | .onUploadComplete(() => {}), 18 | } satisfies FileRouter; 19 | 20 | export type OurFileRouter = typeof ourFileRouter; 21 | -------------------------------------------------------------------------------- /src/hooks/create-workspace-values.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | type CreateWorkSpaceValues = { 4 | name: string; 5 | imageUrl: string; 6 | updateImageUrl: (url: string) => void; 7 | updateValues: (values: Partial) => void; 8 | currStep: number; 9 | setCurrStep: (step: number) => void; 10 | }; 11 | 12 | export const useCreateWorkspaceValues = create(set => ({ 13 | name: '', 14 | imageUrl: '', 15 | updateImageUrl: url => set({ imageUrl: url }), 16 | updateValues: values => set(values), 17 | currStep: 1, 18 | setCurrStep: step => set({ currStep: step }), 19 | })); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/app/create-workspace/[invite]/page.ts: -------------------------------------------------------------------------------- 1 | import { workspaceInvite } from '@/actions/workspaces'; 2 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 3 | import { redirect } from 'next/navigation'; 4 | 5 | const InvitePage = async ({ 6 | params: { invite: inviteCode }, 7 | }: { 8 | params: { invite: string }; 9 | }) => { 10 | await workspaceInvite(inviteCode); 11 | 12 | const supabase = await supabaseServerClient(); 13 | 14 | const { data } = await supabase 15 | .from('workspaces') 16 | .select('*') 17 | .eq('invite_code', inviteCode) 18 | .single(); 19 | 20 | if (data) { 21 | redirect(`/workspace/${data.id}`); 22 | } else { 23 | redirect('/create-workspace'); 24 | } 25 | }; 26 | 27 | export default InvitePage; 28 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Toaster } from 'sonner'; 3 | import { Lato } from 'next/font/google'; 4 | 5 | import '@/styles/globals.css'; 6 | 7 | const lato = Lato({ 8 | subsets: ['latin'], 9 | weight: ['100', '300', '400', '700', '900'], 10 | }); 11 | 12 | export const metadata: Metadata = { 13 | title: 'Slackzz', 14 | description: 'Slack clone codewithlari', 15 | }; 16 | 17 | export const revalidate = 0; 18 | 19 | export default function RootLayout({ 20 | children, 21 | }: Readonly<{ 22 | children: React.ReactNode; 23 | }>) { 24 | return ( 25 | 26 | 27 |
{children}
28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/api/web-socket/io.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from 'next'; 2 | import { Server as NetServer } from 'http'; 3 | import { Server as SockerServer } from 'socket.io'; 4 | 5 | import { SockerIoApiResponse } from '@/types/app'; 6 | 7 | const initializeSocketServer = (httpServer: NetServer): SockerServer => { 8 | const path = '/api/web-socket/io'; 9 | return new SockerServer(httpServer, { 10 | path, 11 | addTrailingSlash: false, 12 | }); 13 | }; 14 | 15 | const handler = async (req: NextApiRequest, res: SockerIoApiResponse) => { 16 | if (!res.socket.server.io) { 17 | res.socket.server.io = initializeSocketServer( 18 | res.socket.server.io as unknown as NetServer 19 | ); 20 | } 21 | 22 | res.end(); 23 | }; 24 | 25 | export default handler; 26 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/supabase/supabaseServer.ts: -------------------------------------------------------------------------------- 1 | import { type CookieOptions, createServerClient } from '@supabase/ssr'; 2 | import { cookies } from 'next/headers'; 3 | 4 | export async function supabaseServerClient() { 5 | const cookieStore = cookies(); 6 | 7 | const supabase = createServerClient( 8 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 9 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 10 | { 11 | cookies: { 12 | get(name: string) { 13 | return cookieStore.get(name)?.value; 14 | }, 15 | set(name: string, value: string, options: CookieOptions) { 16 | cookieStore.set({ name, value, ...options }); 17 | }, 18 | remove(name: string, options: CookieOptions) { 19 | cookieStore.set({ name, value: '', ...options }); 20 | }, 21 | }, 22 | } 23 | ); 24 | 25 | // supabase.auth.getUser(); 26 | 27 | return supabase; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )) 26 | Progress.displayName = ProgressPrimitive.Root.displayName 27 | 28 | export { Progress } 29 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react'; 2 | 3 | import { ColorPrefrencesProvider } from '@/providers/color-prefrences'; 4 | import { ThemeProvider } from '@/providers/theme-provider'; 5 | import MainContent from '@/components/main-content'; 6 | import { WebSocketProvider } from '@/providers/web-socket'; 7 | import { QueryProvider } from '@/providers/query-provider'; 8 | 9 | const MainLayout: FC<{ children: ReactNode }> = ({ children }) => { 10 | return ( 11 | 17 | 18 | 19 | 20 | {children} 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default MainLayout; 29 | -------------------------------------------------------------------------------- /src/supabase/supabaseSeverPages.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { 3 | createServerClient, 4 | serialize, 5 | type CookieOptions, 6 | } from '@supabase/ssr'; 7 | 8 | export default function supabaseServerClientPages( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | const supabase = createServerClient( 13 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 14 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 15 | { 16 | cookies: { 17 | get(name: string) { 18 | return req.cookies[name]; 19 | }, 20 | set(name: string, value: string, options: CookieOptions) { 21 | res.appendHeader('Set-Cookie', serialize(name, value, options)); 22 | }, 23 | remove(name: string, options: CookieOptions) { 24 | res.appendHeader('Set-Cookie', serialize(name, '', options)); 25 | }, 26 | }, 27 | } 28 | ); 29 | 30 | return supabase; 31 | } 32 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /src/components/main-content.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTheme } from 'next-themes'; 4 | import { FC, ReactNode } from 'react'; 5 | 6 | import { useColorPrefrences } from '@/providers/color-prefrences'; 7 | import { cn } from '@/lib/utils'; 8 | 9 | const MainContent: FC<{ children: ReactNode }> = ({ children }) => { 10 | const { theme } = useTheme(); 11 | const { color } = useColorPrefrences(); 12 | 13 | let backgroundColor = 'bg-primary-dark'; 14 | if (color === 'green') { 15 | backgroundColor = 'bg-green-700'; 16 | } else if (color === 'blue') { 17 | backgroundColor = 'bg-blue-700'; 18 | } 19 | 20 | return ( 21 |
24 |
30 | {children} 31 |
32 |
33 | ); 34 | }; 35 | 36 | export default MainContent; 37 | -------------------------------------------------------------------------------- /src/components/intro-banner.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import { FC } from 'react'; 3 | 4 | type IntroBannerProps = { 5 | type: 'Channel' | 'DirectMessage'; 6 | name: string; 7 | creationDate: string; 8 | }; 9 | 10 | const IntroBanner: FC = ({ creationDate, name, type }) => { 11 | const channelMessge = creationDate 12 | ? `You created this channel on ${format( 13 | new Date(creationDate), 14 | 'd MMM yyyy' 15 | )}. This is the very beginning of the ${name} channel. This channel is for everything ${name}. Hold meetings, share docs, and make decisions together.` 16 | : ''; 17 | 18 | const directMessage = `This is the beginning of your direct message history with ${name}. Use this space to share thoughts, files, and more.`; 19 | 20 | return ( 21 |
22 | {type === 'Channel' &&

{channelMessge}

} 23 | {type === 'DirectMessage' && ( 24 |

25 | {directMessage} 26 |

27 | )} 28 |
29 | ); 30 | }; 31 | 32 | export default IntroBanner; 33 | -------------------------------------------------------------------------------- /src/hooks/use-chat-scroll-handler.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useState } from 'react'; 2 | 3 | type UseChatScrollHandlerProps = { 4 | chatRef: RefObject; 5 | bottomRef: RefObject; 6 | count: number; 7 | }; 8 | 9 | export const useChatScrollHandler = ({ 10 | chatRef, 11 | bottomRef, 12 | count, 13 | }: UseChatScrollHandlerProps) => { 14 | const [hasInitializaed, setHasInitialized] = useState(false); 15 | 16 | useEffect(() => { 17 | const bottomDiv = bottomRef?.current; 18 | const topDiv = chatRef?.current; 19 | 20 | const shouldAutoScroll = () => { 21 | if (!hasInitializaed && bottomDiv) { 22 | setHasInitialized(true); 23 | return true; 24 | } 25 | 26 | if (!topDiv) return false; 27 | 28 | const distanceFromBottom = 29 | topDiv.scrollHeight - topDiv.scrollTop - topDiv.clientHeight; 30 | 31 | return distanceFromBottom <= 100; 32 | }; 33 | 34 | if (shouldAutoScroll()) { 35 | setTimeout(() => { 36 | bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 37 | }, 100); 38 | } 39 | }, [bottomRef, chatRef, hasInitializaed, count]); 40 | }; 41 | -------------------------------------------------------------------------------- /src/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: await at.toJwt() }); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/image-upload.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { ImCancelCircle } from 'react-icons/im'; 3 | 4 | import { useCreateWorkspaceValues } from '@/hooks/create-workspace-values'; 5 | import { UploadDropzone } from '@/lib/uploadthing'; 6 | 7 | const ImageUpload = () => { 8 | const { imageUrl, updateImageUrl } = useCreateWorkspaceValues(); 9 | 10 | if (imageUrl) { 11 | return ( 12 |
13 | workspace 20 | updateImageUrl('')} 23 | className='absolute cursor-pointer -right-2 -top-2 z-10 hover:scale-110' 24 | /> 25 |
26 | ); 27 | } 28 | 29 | return ( 30 | { 33 | updateImageUrl(res?.[0].url); 34 | }} 35 | onUploadError={err => console.log(err)} 36 | /> 37 | ); 38 | }; 39 | 40 | export default ImageUpload; 41 | -------------------------------------------------------------------------------- /src/actions/get-user-workspace-channels.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 4 | import { Channel } from '@/types/app'; 5 | 6 | export const getUserWorkspaceChannels = async ( 7 | workspaceId: string, 8 | userId: string 9 | ) => { 10 | const supabase = await supabaseServerClient(); 11 | 12 | const { data: workspaceData, error: workspaceError } = await supabase 13 | .from('workspaces') 14 | .select('channels') 15 | .eq('id', workspaceId) 16 | .single(); 17 | 18 | if (workspaceError) { 19 | console.error(workspaceError); 20 | return []; 21 | } 22 | 23 | const channelIds = workspaceData.channels; 24 | 25 | if (!channelIds || channelIds.length === 0) { 26 | console.log('No channels found'); 27 | return []; 28 | } 29 | 30 | const { data: channelsData, error: channelsError } = await supabase 31 | .from('channels') 32 | .select('*') 33 | .in('id', channelIds); 34 | 35 | if (channelsError) { 36 | console.error(channelsError); 37 | return []; 38 | } 39 | 40 | const userWorkspaceChannels = channelsData.filter(channel => 41 | channel.members.includes(userId) 42 | ); 43 | 44 | return userWorkspaceChannels as Channel[]; 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/ui/typography.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | import { FC, HTMLAttributes } from 'react'; 3 | 4 | type TypographyProps = { 5 | variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'; 6 | text: string; 7 | className?: string; 8 | } & HTMLAttributes; 9 | 10 | const Typography: FC = ({ 11 | variant = 'h1', 12 | text, 13 | className, 14 | ...props 15 | }) => { 16 | const classNames = { 17 | h1: 'scroll-m-20 text-4xl font-extra-bold tracking-tight lg:text-5xl', 18 | h2: 'scroll-m-16 text-3xl font-bold tracking-tight lg:text-4xl', 19 | h3: 'scroll-m-12 text-2xl font-semi-bold tracking-tight lg:text-3xl', 20 | h4: 'scroll-m-10 text-xl font-medium tracking-tight lg:text-2xl', 21 | h5: 'scroll-m-8 text-lg font-normal tracking-tight lg:text-xl', 22 | h6: 'scroll-m-6 text-base font-normal tracking-tight lg:text-xl', 23 | p: 'scroll-m-4 text-sm font-normal tracking-tight lg:text-base', 24 | }; 25 | 26 | const Tag = variant; 27 | const defaultClassName = classNames[variant]; 28 | const combinedClassName = cn(defaultClassName, className); 29 | 30 | return ( 31 | 32 | {text} 33 | 34 | ); 35 | }; 36 | 37 | export default Typography; 38 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /src/hooks/use-chat-file.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | 5 | import { supabaseBrowserClient } from '@/supabase/supabaseClient'; 6 | 7 | export const useChatFile = (filePath: string) => { 8 | const [publicUrl, setPublicUrl] = useState(''); 9 | const [fileType, setFileType] = useState(''); 10 | const [loading, setLoading] = useState(true); 11 | const [error, setError] = useState(null); 12 | 13 | const supabase = supabaseBrowserClient; 14 | 15 | useEffect(() => { 16 | const fetchFile = async () => { 17 | try { 18 | const { 19 | data: { publicUrl }, 20 | } = await supabase.storage.from('chat-files').getPublicUrl(filePath); 21 | 22 | if (publicUrl) { 23 | setPublicUrl(publicUrl); 24 | 25 | if (filePath.startsWith('chat/img-')) { 26 | setFileType('image'); 27 | } else if (filePath.startsWith('chat/pdf-')) { 28 | setFileType('pdf'); 29 | } 30 | } 31 | } catch (error: any) { 32 | setError(error); 33 | } finally { 34 | setLoading(false); 35 | } 36 | }; 37 | 38 | if (filePath) { 39 | fetchFile(); 40 | } 41 | }, [filePath, supabase.storage]); 42 | 43 | return { publicUrl, fileType, loading, error }; 44 | }; 45 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/no-data-component.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { FC, useState } from 'react'; 4 | 5 | import Typography from '@/components/ui/typography'; 6 | import { Button } from '@/components/ui/button'; 7 | import CreateChannelDialog from '@/components/create-channel-dialog'; 8 | 9 | const NoDataScreen: FC<{ 10 | workspaceName: string; 11 | userId: string; 12 | workspaceId: string; 13 | }> = ({ userId, workspaceId, workspaceName }) => { 14 | const [dialogOpen, setDialogOpen] = useState(false); 15 | 16 | return ( 17 |
18 | 22 | 27 | 28 |
29 | 32 |
33 | 34 | 40 |
41 | ); 42 | }; 43 | 44 | export default NoDataScreen; 45 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /src/components/video-chat.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react'; 2 | import '@livekit/components-styles'; 3 | import { 4 | LiveKitRoom, 5 | VideoConference, 6 | RoomAudioRenderer, 7 | } from '@livekit/components-react'; 8 | 9 | import { User } from '@/types/app'; 10 | import DotAnimatedLoader from './dot-animated-loader'; 11 | 12 | type VideoChatProps = { 13 | chatId: string; 14 | userData: User; 15 | }; 16 | 17 | const VideoChat: FC = ({ chatId, userData }) => { 18 | const [token, setToken] = useState(''); 19 | 20 | useEffect(() => { 21 | const name = userData.email; 22 | 23 | (async () => { 24 | try { 25 | const resp = await fetch( 26 | `/api/livekit?room=${chatId}&username=${name}` 27 | ); 28 | const data = await resp.json(); 29 | setToken(data.token); 30 | } catch (e) { 31 | console.error(e); 32 | } 33 | })(); 34 | }, [chatId, userData.email]); 35 | 36 | if (token === '') return ; 37 | 38 | return ( 39 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default VideoChat; 54 | -------------------------------------------------------------------------------- /src/components/chat-header.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { IoMdHeadset } from 'react-icons/io'; 3 | 4 | import Typography from '@/components/ui/typography'; 5 | import { User } from '@/types/app'; 6 | import { useRouter, useSearchParams } from 'next/navigation'; 7 | 8 | type ChatHeaderProp = { title: string; chatId: string; userData: User }; 9 | 10 | const ChatHeader: FC = ({ title, chatId, userData }) => { 11 | const router = useRouter(); 12 | const searchParams = useSearchParams(); 13 | 14 | const handleCall = () => { 15 | const currentParams = new URLSearchParams(searchParams?.toString()); 16 | if (currentParams.has('call')) { 17 | currentParams.delete('call'); 18 | } else { 19 | currentParams.set('call', 'true'); 20 | } 21 | 22 | router.push(`?${currentParams.toString()}`); 23 | }; 24 | 25 | return ( 26 |
27 |
28 | 29 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default ChatHeader; 40 | -------------------------------------------------------------------------------- /src/actions/get-user-data.ts: -------------------------------------------------------------------------------- 1 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | import { User } from '@/types/app'; 5 | import supabaseServerClientPages from '@/supabase/supabaseSeverPages'; 6 | 7 | export const getUserData = async (): Promise => { 8 | const supabase = await supabaseServerClient(); 9 | 10 | const { 11 | data: { user }, 12 | } = await supabase.auth.getUser(); 13 | 14 | if (!user) { 15 | console.log('NO USER', user); 16 | return null; 17 | } 18 | 19 | const { data, error } = await supabase 20 | .from('users') 21 | .select('*') 22 | .eq('id', user.id); 23 | 24 | if (error) { 25 | console.log(error); 26 | return null; 27 | } 28 | 29 | return data ? data[0] : null; 30 | }; 31 | 32 | export const getUserDataPages = async ( 33 | req: NextApiRequest, 34 | res: NextApiResponse 35 | ): Promise => { 36 | const supabase = supabaseServerClientPages(req, res); 37 | 38 | const { 39 | data: { user }, 40 | } = await supabase.auth.getUser(); 41 | 42 | if (!user) { 43 | console.log('NO USER', user); 44 | return null; 45 | } 46 | 47 | const { data, error } = await supabase 48 | .from('users') 49 | .select('*') 50 | .eq('id', user.id); 51 | 52 | if (error) { 53 | console.log(error); 54 | return null; 55 | } 56 | 57 | return data ? data[0] : null; 58 | }; 59 | -------------------------------------------------------------------------------- /src/hooks/use-chat-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery } from '@tanstack/react-query'; 2 | 3 | import { useSocket } from '@/providers/web-socket'; 4 | import { MessageWithUser } from '@/types/app'; 5 | import axios from 'axios'; 6 | 7 | type ChatFetcherProps = { 8 | queryKey: string; 9 | apiUrl: string; 10 | paramKey: 'channelId' | 'recipientId'; 11 | paramValue: string; 12 | pageSize: number; 13 | }; 14 | 15 | export const useChatFetcher = ({ 16 | apiUrl, 17 | queryKey, 18 | pageSize, 19 | paramKey, 20 | paramValue, 21 | }: ChatFetcherProps) => { 22 | const { isConnected } = useSocket(); 23 | 24 | const fetcher = async ({ 25 | pageParam = 0, 26 | }: any): Promise<{ data: MessageWithUser[] }> => { 27 | const url = `${apiUrl}?${paramKey}=${encodeURIComponent( 28 | paramValue 29 | )}&page=${pageParam}&size=${pageSize}`; 30 | 31 | const { data } = await axios.get(url); 32 | 33 | return data as any; 34 | }; 35 | 36 | return useInfiniteQuery<{ data: MessageWithUser[] }, Error>({ 37 | queryKey: [queryKey, paramValue], 38 | queryFn: fetcher, 39 | getNextPageParam: (lastPage, allPages) => 40 | lastPage.data.length === pageSize ? allPages.length : undefined, 41 | refetchInterval: isConnected ? false : 1000, 42 | retry: 3, 43 | staleTime: 5 * 60 * 1000, 44 | refetchOnWindowFocus: true, 45 | refetchOnReconnect: true, 46 | initialPageParam: 0, 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /src/app/(authentication)/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { NextResponse } from 'next/server'; 3 | import { type CookieOptions, createServerClient } from '@supabase/ssr'; 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams, origin } = new URL(request.url); 7 | const code = searchParams.get('code'); 8 | // if "next" is in param, use it as the redirect URL 9 | // const next = searchParams.get('next') ?? '/'; 10 | 11 | if (code) { 12 | const cookieStore = cookies(); 13 | const supabase = createServerClient( 14 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 16 | { 17 | cookies: { 18 | get(name: string) { 19 | return cookieStore.get(name)?.value; 20 | }, 21 | set(name: string, value: string, options: CookieOptions) { 22 | cookieStore.set({ name, value, ...options }); 23 | }, 24 | remove(name: string, options: CookieOptions) { 25 | cookieStore.delete({ name, ...options }); 26 | }, 27 | }, 28 | } 29 | ); 30 | const { error } = await supabase.auth.exchangeCodeForSession(code); 31 | if (!error) { 32 | return NextResponse.redirect(`${origin}`); // Changed from `${origin}${next}` 33 | } 34 | } 35 | 36 | // return the user to an error page with instructions 37 | return NextResponse.redirect(`${origin}/auth/auth-code-error`); 38 | } 39 | -------------------------------------------------------------------------------- /src/types/app.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from 'next'; 2 | import { Server as NetServer, Socket } from 'net'; 3 | import { Server as SocketIOServer } from 'socket.io'; 4 | 5 | export type User = { 6 | avatar_url: string; 7 | channels: string[] | null; 8 | created_at: string | null; 9 | email: string; 10 | id: string; 11 | is_away: boolean; 12 | name: string | null; 13 | phone: string | null; 14 | type: string | null; 15 | workspaces: string[] | null; 16 | }; 17 | 18 | export type Workspace = { 19 | channels: string[] | null; 20 | created_at: string; 21 | id: string; 22 | image_url: string | null; 23 | invite_code: string | null; 24 | members: User[] | null; 25 | name: string; 26 | regulators: string[] | null; 27 | slug: string; 28 | super_admin: string; 29 | }; 30 | 31 | export type Channel = { 32 | id: string; 33 | members: string[] | null; 34 | name: string; 35 | regulators: string[] | null; 36 | user_id: string; 37 | workspace_id: string; 38 | created_at: string; 39 | }; 40 | 41 | export type Messages = { 42 | channel_id: string; 43 | content: string | null; 44 | created_at: string; 45 | file_url: string | null; 46 | id: string; 47 | is_deleted: boolean; 48 | updated_at: string; 49 | user_id: string; 50 | workspace_id: string; 51 | }; 52 | 53 | export type MessageWithUser = Messages & { user: User }; 54 | 55 | export type SockerIoApiResponse = NextApiResponse & { 56 | socket: Socket & { 57 | server: NetServer & { 58 | io: SocketIOServer; 59 | }; 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /src/actions/create-workspace.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 4 | import { getUserData } from './get-user-data'; 5 | import { updateUserWorkspace } from './update-user-workspace'; 6 | import { addMemberToWorkspace } from './add-member-to-workspace'; 7 | 8 | export const createWorkspace = async ({ 9 | imageUrl, 10 | name, 11 | slug, 12 | invite_code, 13 | }: { 14 | imageUrl?: string; 15 | name: string; 16 | slug: string; 17 | invite_code: string; 18 | }) => { 19 | const supabase = await supabaseServerClient(); 20 | const userData = await getUserData(); 21 | 22 | if (!userData) { 23 | return { error: 'No user data' }; 24 | } 25 | 26 | const { error, data: workspaceRecord } = await supabase 27 | .from('workspaces') 28 | .insert({ 29 | image_url: imageUrl, 30 | name, 31 | super_admin: userData.id, 32 | slug, 33 | invite_code, 34 | }) 35 | .select('*'); 36 | 37 | if (error) { 38 | return { error }; 39 | } 40 | 41 | const [updateWorkspaceData, updateWorkspaceError] = await updateUserWorkspace( 42 | userData.id, 43 | workspaceRecord[0].id 44 | ); 45 | 46 | if (updateWorkspaceError) { 47 | return { error: updateWorkspaceError }; 48 | } 49 | 50 | // Add user to workspace members 51 | const [addMemberToWorkspaceData, addMemberToWorkspaceError] = 52 | await addMemberToWorkspace(userData.id, workspaceRecord[0].id); 53 | 54 | if (addMemberToWorkspaceError) { 55 | return { error: addMemberToWorkspaceError }; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/providers/web-socket.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | FC, 5 | ReactNode, 6 | createContext, 7 | useContext, 8 | useEffect, 9 | useState, 10 | } from 'react'; 11 | import { io as ClientIO, Socket } from 'socket.io-client'; 12 | 13 | type SocketContextType = { 14 | socket: Socket | null; 15 | isConnected: boolean; 16 | }; 17 | 18 | const SocketContext = createContext({ 19 | isConnected: false, 20 | socket: null, 21 | }); 22 | 23 | export const useSocket = () => useContext(SocketContext); 24 | 25 | export const WebSocketProvider: FC<{ children: ReactNode }> = ({ 26 | children, 27 | }) => { 28 | const [socket, setSocket] = useState(null); 29 | const [isConnected, setIsConnected] = useState(false); 30 | 31 | useEffect(() => { 32 | const siteUrl = process.env.NEXT_PUBLIC_SITE_URL; 33 | 34 | if (!siteUrl) { 35 | console.log('NEXT_PUBLIC_SITE_URL is not defined'); 36 | return; 37 | } 38 | 39 | const socketInstance = ClientIO(siteUrl, { 40 | path: '/api/web-socket/io', 41 | addTrailingSlash: false, 42 | }); 43 | 44 | socketInstance.on('connect', () => { 45 | setIsConnected(true); 46 | }); 47 | 48 | socketInstance.on('disconnect', () => { 49 | setIsConnected(false); 50 | }); 51 | 52 | setSocket(socketInstance); 53 | 54 | return () => { 55 | socketInstance.disconnect(); 56 | }; 57 | }, []); 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/providers/color-prefrences.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | FC, 5 | ReactNode, 6 | createContext, 7 | useContext, 8 | useEffect, 9 | useState, 10 | } from 'react'; 11 | 12 | type Colors = 'blue' | 'green' | ''; 13 | 14 | type ColorPrefrencesContext = { 15 | color: Colors; 16 | selectColor: (color: Colors) => void; 17 | }; 18 | 19 | const ColorPrefrencesContext = createContext< 20 | ColorPrefrencesContext | undefined 21 | >(undefined); 22 | 23 | export const useColorPrefrences = () => { 24 | const context = useContext(ColorPrefrencesContext); 25 | if (!context) { 26 | throw new Error( 27 | 'useColorPrefrences must be used within a ColorPrefrencesProvider' 28 | ); 29 | } 30 | 31 | return context; 32 | }; 33 | 34 | export const ColorPrefrencesProvider: FC<{ children: ReactNode }> = ({ 35 | children, 36 | }) => { 37 | const [color, setColor] = useState(() => { 38 | const storedColor = 39 | typeof localStorage !== 'undefined' 40 | ? localStorage.getItem('selectedColor') 41 | : null; 42 | return (storedColor as Colors) || ''; 43 | }); 44 | const [isMounted, setIsMounted] = useState(false); 45 | 46 | useEffect(() => { 47 | localStorage.setItem('selectedColor', color); 48 | setIsMounted(true); 49 | }, [color]); 50 | 51 | const selectColor = (selectedColor: Colors) => setColor(selectedColor); 52 | 53 | const value: ColorPrefrencesContext = { 54 | color, 55 | selectColor, 56 | }; 57 | 58 | if (!isMounted) return null; 59 | 60 | return ( 61 | 62 | {children} 63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/app/api/messages/route.ts: -------------------------------------------------------------------------------- 1 | import { getUserData } from '@/actions/get-user-data'; 2 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | function getPagination(page: number, size: number) { 6 | const limit = size ? +size : 10; 7 | const from = page ? page * limit : 0; 8 | const to = page ? from + limit - 1 : limit - 1; 9 | 10 | return { from, to }; 11 | } 12 | 13 | export async function GET(req: Request) { 14 | try { 15 | const supabase = await supabaseServerClient(); 16 | const userData = await getUserData(); 17 | const { searchParams } = new URL(req.url); 18 | const channelId = searchParams.get('channelId'); 19 | 20 | if (!userData) { 21 | return new Response('Unauthorized', { status: 401 }); 22 | } 23 | 24 | if (!channelId) { 25 | return new Response('Bad Request', { status: 400 }); 26 | } 27 | 28 | const page = Number(searchParams.get('page')); 29 | const size = Number(searchParams.get('size')); 30 | 31 | const { from, to } = getPagination(page, size); 32 | 33 | const { data, error } = await supabase 34 | .from('messages') 35 | .select('*, user: user_id (*)') 36 | .eq('channel_id', channelId) 37 | .range(from, to) 38 | .order('created_at', { ascending: false }); 39 | 40 | if (error) { 41 | console.log('GET MESSAGES ERROR: ', error); 42 | return new Response('Bad Request', { status: 400 }); 43 | } 44 | 45 | return NextResponse.json({ data }); 46 | } catch (error) { 47 | console.log('SERVER ERROR: ', error); 48 | return new Response('Internal Server Error', { status: 500 }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/(authentication)/auth/confirm/route.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient, type CookieOptions } from '@supabase/ssr'; 2 | import { type EmailOtpType } from '@supabase/supabase-js'; 3 | import { cookies } from 'next/headers'; 4 | import { NextRequest, NextResponse } from 'next/server'; 5 | 6 | export async function GET(request: NextRequest) { 7 | const { searchParams } = new URL(request.url); 8 | const token_hash = searchParams.get('token_hash'); 9 | const type = searchParams.get('type') as EmailOtpType | null; 10 | const next = searchParams.get('next') ?? '/'; 11 | const redirectTo = request.nextUrl.clone(); 12 | redirectTo.pathname = next; 13 | 14 | if (token_hash && type) { 15 | const cookieStore = cookies(); 16 | const supabase = createServerClient( 17 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 18 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 19 | { 20 | cookies: { 21 | get(name: string) { 22 | return cookieStore.get(name)?.value; 23 | }, 24 | set(name: string, value: string, options: CookieOptions) { 25 | cookieStore.set({ name, value, ...options }); 26 | }, 27 | remove(name: string, options: CookieOptions) { 28 | cookieStore.delete({ name, ...options }); 29 | }, 30 | }, 31 | } 32 | ); 33 | 34 | const { error } = await supabase.auth.verifyOtp({ 35 | type, 36 | token_hash, 37 | }); 38 | if (!error) { 39 | return NextResponse.redirect(redirectTo); 40 | } 41 | } 42 | 43 | // return the user to an error page with some instructions 44 | redirectTo.pathname = '/auth/auth-code-error'; 45 | return NextResponse.redirect(redirectTo); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /src/pages/api/web-socket/direct-messages/index.ts: -------------------------------------------------------------------------------- 1 | import { SockerIoApiResponse } from '@/types/app'; 2 | import { NextApiRequest } from 'next'; 3 | 4 | import { getUserDataPages } from '@/actions/get-user-data'; 5 | import supabaseServerClientPages from '@/supabase/supabaseSeverPages'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: SockerIoApiResponse 10 | ) { 11 | if (req.method !== 'POST') { 12 | return res.status(405).json({ error: 'Method not allowed' }); 13 | } 14 | 15 | try { 16 | const userData = await getUserDataPages(req, res); 17 | 18 | if (!userData) { 19 | return res.status(401).json({ error: 'Unauthorized' }); 20 | } 21 | 22 | const { recipientId } = req.query; 23 | 24 | if (!recipientId) { 25 | return res.status(400).json({ error: 'Invalid request' }); 26 | } 27 | 28 | const { content, fileUrl } = req.body; 29 | 30 | const supabase = supabaseServerClientPages(req, res); 31 | 32 | const { data, error: sendingMessageError } = await supabase 33 | .from('direct_messages') 34 | .insert({ 35 | content, 36 | file_url: fileUrl, 37 | user: userData.id, 38 | user_one: userData.id, 39 | user_two: recipientId, 40 | }) 41 | .select('*, user (*), user_one (*), user_two (*)') 42 | .order('created_at', { ascending: false }) 43 | .single(); 44 | 45 | if (sendingMessageError) { 46 | console.log('DIRECT MESSAGE ERROR: ', sendingMessageError); 47 | return res.status(500).json({ error: 'Error sending message' }); 48 | } 49 | 50 | res?.socket?.server?.io?.emit('direct-message:post', data); 51 | 52 | return res.status(200).json({ message: 'Message sent' }); 53 | } catch (error) { 54 | console.log('DIRECT MESSAGE ERROR: ', error); 55 | return res.status(500).json({ error: 'Error sending message' }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/api/[direct-messages]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | import { getUserData } from '@/actions/get-user-data'; 4 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 5 | 6 | function getPagination(page: number, size: number) { 7 | const limit = size ? +size : 10; 8 | const from = page ? page * limit : 0; 9 | const to = page ? from + limit - 1 : limit - 1; 10 | 11 | return { from, to }; 12 | } 13 | 14 | export async function GET(req: Request) { 15 | try { 16 | const supabase = await supabaseServerClient(); 17 | const userData = await getUserData(); 18 | 19 | if (!userData) return new NextResponse('Unauthorized', { status: 401 }); 20 | 21 | const { searchParams } = new URL(req.url); 22 | const userId = userData.id; 23 | const recipientId = searchParams.get('recipientId'); 24 | 25 | if (!recipientId) return new NextResponse('Bad Request', { status: 400 }); 26 | 27 | const page = Number(searchParams.get('page')); 28 | const size = Number(searchParams.get('size')); 29 | 30 | const { from, to } = getPagination(page, size); 31 | 32 | const { data, error } = await supabase 33 | .from('direct_messages') 34 | .select(`*, user_one:user_one (*), user_two:user_two (*), user: user (*)`) 35 | .or( 36 | `and(user_one.eq.${userId}), user_two.eq.${recipientId}, and(user_one.eq.${recipientId}), user_two.eq.${userId})` 37 | ) 38 | .range(from, to) 39 | .order('created_at', { ascending: true }); 40 | 41 | if (error) { 42 | console.error('Error fetching direct messages', error); 43 | return new NextResponse('Internal Server Error', { status: 500 }); 44 | } 45 | 46 | return NextResponse.json({ data }); 47 | } catch (error) { 48 | console.error('Error fetching direct messages', error); 49 | return new NextResponse('Internal Server Error', { status: 500 }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/(main)/workspace/[workspaceId]/direct-message/[chatId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { getUserData } from '@/actions/get-user-data'; 4 | import ChatGroup from '@/components/chat-group'; 5 | import { 6 | getCurrentWorksaceData, 7 | getUserWorkspaceData, 8 | } from '@/actions/workspaces'; 9 | import { getUserWorkspaceChannels } from '@/actions/get-user-workspace-channels'; 10 | import { Workspace } from '@/types/app'; 11 | 12 | const DirectMessage = async ({ 13 | params: { chatId, workspaceId }, 14 | }: { 15 | params: { workspaceId: string; chatId: string }; 16 | }) => { 17 | const userData = await getUserData(); 18 | 19 | if (!userData) return redirect('/auth'); 20 | 21 | const [userWorkspacesData] = await getUserWorkspaceData(userData.workspaces!); 22 | 23 | const [currentWorkspaceData] = await getCurrentWorksaceData(workspaceId); 24 | 25 | const userWorkspaceChannels = await getUserWorkspaceChannels( 26 | workspaceId, 27 | userData.id 28 | ); 29 | 30 | const currentChannelData = userWorkspaceChannels.find( 31 | channel => channel.id === chatId 32 | ); 33 | 34 | return ( 35 |
36 | 56 |
57 | ); 58 | }; 59 | 60 | export default DirectMessage; 61 | -------------------------------------------------------------------------------- /src/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 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/app/(main)/workspace/[workspaceId]/channels/[channelId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { getUserData } from '@/actions/get-user-data'; 4 | import { Workspace as UserWorkspace } from '@/types/app'; 5 | import { 6 | getCurrentWorksaceData, 7 | getUserWorkspaceData, 8 | } from '@/actions/workspaces'; 9 | import { getUserWorkspaceChannels } from '@/actions/get-user-workspace-channels'; 10 | import ChatGroup from '@/components/chat-group'; 11 | 12 | const ChannelId = async ({ 13 | params: { channelId, workspaceId }, 14 | }: { 15 | params: { 16 | workspaceId: string; 17 | channelId: string; 18 | }; 19 | }) => { 20 | const userData = await getUserData(); 21 | 22 | if (!userData) return redirect('/auth'); 23 | 24 | const [userWorkspaceData] = await getUserWorkspaceData(userData.workspaces!); 25 | 26 | const [currentWorkspaceData] = await getCurrentWorksaceData(workspaceId); 27 | 28 | const userWorkspaceChannels = await getUserWorkspaceChannels( 29 | currentWorkspaceData.id, 30 | userData.id 31 | ); 32 | 33 | const currentChannelData = userWorkspaceChannels.find( 34 | channel => channel.id === channelId 35 | ); 36 | 37 | if (!currentChannelData) return redirect('/'); 38 | 39 | return ( 40 |
41 | 60 |
61 | ); 62 | }; 63 | 64 | export default ChannelId; 65 | -------------------------------------------------------------------------------- /src/hooks/use-chat-socket-connection.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import { useSocket } from '@/providers/web-socket'; 4 | import { MessageWithUser } from '@/types/app'; 5 | import { useEffect } from 'react'; 6 | 7 | type UseChatSocketConnectionProps = { 8 | addKey: string; 9 | queryKey: string; 10 | updateKey: string; 11 | paramValue: string; 12 | }; 13 | 14 | export const useChatSocketConnection = ({ 15 | addKey, 16 | paramValue, 17 | updateKey, 18 | queryKey, 19 | }: UseChatSocketConnectionProps) => { 20 | const { socket } = useSocket(); 21 | const queryClient = useQueryClient(); 22 | 23 | const handleUpdateMessage = (message: any) => { 24 | queryClient.setQueryData([queryKey, paramValue], (prev: any) => { 25 | if (!prev || !prev.pages || !prev.pages.length) return prev; 26 | 27 | const newData = prev.pages.map((page: any) => ({ 28 | ...page, 29 | data: page.data.map((data: MessageWithUser) => { 30 | if (data.id === message.id) { 31 | return message; 32 | } 33 | return data; 34 | }), 35 | })); 36 | 37 | return { 38 | ...prev, 39 | pages: newData, 40 | }; 41 | }); 42 | }; 43 | 44 | const handleNewMessage = (message: MessageWithUser) => { 45 | queryClient.setQueryData([queryKey, paramValue], (prev: any) => { 46 | if (!prev || !prev.pages || prev.pages.length === 0) return prev; 47 | 48 | const newPages = [...prev.pages]; 49 | newPages[0] = { 50 | ...newPages[0], 51 | data: [message, ...newPages[0].data], 52 | }; 53 | 54 | return { 55 | ...prev, 56 | pages: newPages, 57 | }; 58 | }); 59 | }; 60 | 61 | useEffect(() => { 62 | if (!socket) return; 63 | 64 | socket.on(updateKey, handleUpdateMessage); 65 | socket.on(addKey, handleNewMessage); 66 | 67 | return () => { 68 | socket.off(updateKey, handleUpdateMessage); 69 | socket.off(addKey, handleNewMessage); 70 | }; 71 | }, [addKey, updateKey, queryKey, queryClient, socket]); 72 | }; 73 | -------------------------------------------------------------------------------- /src/app/(main)/workspace/[workspaceId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { getUserData } from '@/actions/get-user-data'; 4 | import { 5 | getCurrentWorksaceData, 6 | getUserWorkspaceData, 7 | } from '@/actions/workspaces'; 8 | import Sidebar from '@/components/sidebar'; 9 | import { Workspace as UserWorkspace } from '@/types/app'; 10 | import InfoSection from '@/components/info-section'; 11 | import { getUserWorkspaceChannels } from '@/actions/get-user-workspace-channels'; 12 | import NoDataScreen from '@/components/no-data-component'; 13 | 14 | const Workspace = async ({ 15 | params: { workspaceId }, 16 | }: { 17 | params: { workspaceId: string }; 18 | }) => { 19 | const userData = await getUserData(); 20 | 21 | if (!userData) return redirect('/auth'); 22 | 23 | const [userWorkspaceData] = await getUserWorkspaceData(userData.workspaces!); 24 | 25 | const [currentWorkspaceData] = await getCurrentWorksaceData(workspaceId); 26 | 27 | const userWorkspaceChannels = await getUserWorkspaceChannels( 28 | currentWorkspaceData.id, 29 | userData.id 30 | ); 31 | 32 | // if (userWorkspaceChannels.length) { 33 | // redirect( 34 | // `/workspace/${workspaceId}/channels/${userWorkspaceChannels[0].id}` 35 | // ); 36 | // } 37 | 38 | return ( 39 | <> 40 |
41 | 46 | 52 | 53 | 58 |
59 |
Mobile
60 | 61 | ); 62 | }; 63 | 64 | export default Workspace; 65 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | .tiptap.ProseMirror { 79 | outline: none; 80 | } 81 | 82 | .tiptap p.is-editor-empty:first-child::before { 83 | color: #adb5bd; 84 | content: attr(data-placeholder); 85 | float: left; 86 | height: 0; 87 | pointer-events: none; 88 | } 89 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient, type CookieOptions } from '@supabase/ssr'; 2 | import { NextResponse, type NextRequest } from 'next/server'; 3 | 4 | export async function middleware(request: NextRequest) { 5 | let response = NextResponse.next({ 6 | request: { 7 | headers: request.headers, 8 | }, 9 | }); 10 | 11 | const supabase = createServerClient( 12 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 13 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 14 | { 15 | cookies: { 16 | get(name: string) { 17 | return request.cookies.get(name)?.value; 18 | }, 19 | set(name: string, value: string, options: CookieOptions) { 20 | request.cookies.set({ 21 | name, 22 | value, 23 | ...options, 24 | }); 25 | response = NextResponse.next({ 26 | request: { 27 | headers: request.headers, 28 | }, 29 | }); 30 | response.cookies.set({ 31 | name, 32 | value, 33 | ...options, 34 | }); 35 | }, 36 | remove(name: string, options: CookieOptions) { 37 | request.cookies.set({ 38 | name, 39 | value: '', 40 | ...options, 41 | }); 42 | response = NextResponse.next({ 43 | request: { 44 | headers: request.headers, 45 | }, 46 | }); 47 | response.cookies.set({ 48 | name, 49 | value: '', 50 | ...options, 51 | }); 52 | }, 53 | }, 54 | } 55 | ); 56 | 57 | await supabase.auth.getUser(); 58 | 59 | return response; 60 | } 61 | 62 | export const config = { 63 | matcher: [ 64 | /* 65 | * Match all request paths except for the ones starting with: 66 | * - _next/static (static files) 67 | * - _next/image (image optimization files) 68 | * - favicon.ico (favicon file) 69 | * Feel free to modify this pattern to include more paths. 70 | */ 71 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', 72 | ], 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /src/pages/api/web-socket/messages/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from 'next'; 2 | 3 | import { getUserDataPages } from '@/actions/get-user-data'; 4 | import supabaseServerClientPages from '@/supabase/supabaseSeverPages'; 5 | import { SockerIoApiResponse } from '@/types/app'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: SockerIoApiResponse 10 | ) { 11 | if (req.method !== 'POST') 12 | return res.status(405).json({ message: 'Method not allowed' }); 13 | 14 | try { 15 | const userData = await getUserDataPages(req, res); 16 | 17 | if (!userData) { 18 | return res.status(401).json({ message: 'Unauthorized' }); 19 | } 20 | 21 | const { channelId, workspaceId } = req.query; 22 | 23 | if (!channelId || !workspaceId) { 24 | return res.status(400).json({ message: 'Bad request' }); 25 | } 26 | 27 | const { content, fileUrl } = req.body; 28 | 29 | if (!content && !fileUrl) { 30 | return res.status(400).json({ message: 'Bad request' }); 31 | } 32 | 33 | const supabase = supabaseServerClientPages(req, res); 34 | 35 | const { data: channelData } = await supabase 36 | .from('channels') 37 | .select('*') 38 | .eq('id', channelId) 39 | .contains('members', [userData.id]); 40 | 41 | if (!channelData?.length) { 42 | return res.status(403).json({ message: 'Channel not found' }); 43 | } 44 | 45 | const { error: creatingMessageError, data } = await supabase 46 | .from('messages') 47 | .insert({ 48 | user_id: userData.id, 49 | workspace_id: workspaceId, 50 | channel_id: channelId, 51 | content, 52 | file_url: fileUrl, 53 | }) 54 | .select('*, user: user_id(*)') 55 | .order('created_at', { ascending: true }) 56 | .single(); 57 | 58 | if (creatingMessageError) { 59 | console.log('MESSAEGE CREATION ERROR: ', creatingMessageError); 60 | return res.status(500).json({ message: 'Internal server error' }); 61 | } 62 | 63 | res?.socket?.server?.io?.emit( 64 | `channel:${channelId}:channel-messages`, 65 | data 66 | ); 67 | 68 | return res.status(201).json({ message: 'Message created', data }); 69 | } catch (error) { 70 | console.log('MESSAEGE CREATION ERROR: ', error); 71 | return res.status(500).json({ message: 'Internal server error' }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slackzz", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@emoji-mart/data": "^1.2.1", 13 | "@emoji-mart/react": "^1.1.1", 14 | "@hookform/resolvers": "^3.3.4", 15 | "@livekit/components-react": "^2.3.3", 16 | "@livekit/components-styles": "^1.0.12", 17 | "@radix-ui/react-avatar": "^1.0.4", 18 | "@radix-ui/react-collapsible": "^1.0.3", 19 | "@radix-ui/react-dialog": "^1.0.5", 20 | "@radix-ui/react-label": "^2.0.2", 21 | "@radix-ui/react-popover": "^1.0.7", 22 | "@radix-ui/react-progress": "^1.0.3", 23 | "@radix-ui/react-scroll-area": "^1.1.0", 24 | "@radix-ui/react-separator": "^1.0.3", 25 | "@radix-ui/react-slot": "^1.0.2", 26 | "@radix-ui/react-tabs": "^1.0.4", 27 | "@radix-ui/react-tooltip": "^1.0.7", 28 | "@supabase/ssr": "^0.3.0", 29 | "@supabase/supabase-js": "^2.42.5", 30 | "@tailwindcss/typography": "^0.5.13", 31 | "@tanstack/react-query": "^5.45.1", 32 | "@tiptap/extension-bold": "^2.4.0", 33 | "@tiptap/extension-placeholder": "^2.4.0", 34 | "@tiptap/react": "^2.4.0", 35 | "@tiptap/starter-kit": "^2.4.0", 36 | "@types/uuid": "^9.0.8", 37 | "@uploadthing/react": "^6.5.0", 38 | "axios": "^1.7.2", 39 | "class-variance-authority": "^0.7.0", 40 | "clsx": "^2.1.0", 41 | "date-fns": "^3.6.0", 42 | "livekit-server-sdk": "^2.5.0", 43 | "lucide-react": "^0.372.0", 44 | "next": "14.2.2", 45 | "next-themes": "^0.3.0", 46 | "react": "^18", 47 | "react-dom": "^18", 48 | "react-hook-form": "^7.51.3", 49 | "react-icons": "^5.1.0", 50 | "slugify": "^1.6.6", 51 | "socket.io": "^4.7.5", 52 | "socket.io-client": "^4.7.5", 53 | "sonner": "^1.4.41", 54 | "supabase": ">=1.8.1", 55 | "tailwind-merge": "^2.3.0", 56 | "tailwindcss-animate": "^1.0.7", 57 | "uploadthing": "^6.10.0", 58 | "uuid": "^9.0.1", 59 | "zod": "^3.23.3", 60 | "zustand": "^4.5.2" 61 | }, 62 | "devDependencies": { 63 | "typescript": "^5", 64 | "@types/node": "^20", 65 | "@types/react": "^18", 66 | "@types/react-dom": "^18", 67 | "postcss": "^8", 68 | "tailwindcss": "^3.4.1", 69 | "eslint": "^8", 70 | "eslint-config-next": "14.2.2" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/actions/workspaces.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 4 | import { getUserData } from '@/actions/get-user-data'; 5 | import { addMemberToWorkspace } from '@/actions/add-member-to-workspace'; 6 | import { updateUserWorkspace } from '@/actions/update-user-workspace'; 7 | 8 | export const getUserWorkspaceData = async (workspaceIds: Array) => { 9 | const supabase = await supabaseServerClient(); 10 | 11 | const { data, error } = await supabase 12 | .from('workspaces') 13 | .select('*') 14 | .in('id', workspaceIds); 15 | 16 | return [data, error]; 17 | }; 18 | 19 | export const getCurrentWorksaceData = async (workspaceId: string) => { 20 | const supabase = await supabaseServerClient(); 21 | 22 | const { data, error } = await supabase 23 | .from('workspaces') 24 | .select('*, channels (*)') 25 | .eq('id', workspaceId) 26 | .single(); 27 | 28 | if (error) { 29 | return [null, error]; 30 | } 31 | 32 | const { members } = data; 33 | 34 | const memberDetails = await Promise.all( 35 | members.map(async (memberId: string) => { 36 | const { data: userData, error: userError } = await supabase 37 | .from('users') 38 | .select('*') 39 | .eq('id', memberId) 40 | .single(); 41 | 42 | if (userError) { 43 | console.log( 44 | `Error fetching user data for member ${memberId}`, 45 | userError 46 | ); 47 | return null; 48 | } 49 | 50 | return userData; 51 | }) 52 | ); 53 | 54 | data.members = memberDetails.filter(member => member !== null); 55 | 56 | return [data, error]; 57 | }; 58 | 59 | export const workspaceInvite = async (inviteCode: string) => { 60 | const supabase = await supabaseServerClient(); 61 | const userData = await getUserData(); 62 | 63 | const { data, error } = await supabase 64 | .from('workspaces') 65 | .select('*') 66 | .eq('invite_code', inviteCode) 67 | .single(); 68 | 69 | if (error) { 70 | console.log('Error fetching workspace invite', error); 71 | return; 72 | } 73 | 74 | const isUserMember = data?.members?.includes(userData?.id); 75 | 76 | if (isUserMember) { 77 | console.log('User is already a member of this workspace'); 78 | return; 79 | } 80 | 81 | if (data?.super_admin === userData?.id) { 82 | console.log('User is the super admin of this workspace'); 83 | return; 84 | } 85 | 86 | await addMemberToWorkspace(userData?.id!, data?.id); 87 | 88 | await updateUserWorkspace(userData?.id!, data?.id); 89 | }; 90 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | import { withUt } from 'uploadthing/tw'; 3 | 4 | const config = { 5 | darkMode: ['class'], 6 | content: [ 7 | './pages/**/*.{ts,tsx}', 8 | './components/**/*.{ts,tsx}', 9 | './app/**/*.{ts,tsx}', 10 | './src/**/*.{ts,tsx}', 11 | ], 12 | prefix: '', 13 | theme: { 14 | container: { 15 | center: true, 16 | padding: '2rem', 17 | screens: { 18 | '2xl': '1400px', 19 | }, 20 | }, 21 | extend: { 22 | colors: { 23 | border: 'hsl(var(--border))', 24 | input: 'hsl(var(--input))', 25 | ring: 'hsl(var(--ring))', 26 | background: 'hsl(var(--background))', 27 | foreground: 'hsl(var(--foreground))', 28 | primary: { 29 | DEFAULT: 'hsl(var(--primary))', 30 | foreground: 'hsl(var(--primary-foreground))', 31 | dark: '#451c49', 32 | light: '#311834', 33 | }, 34 | secondary: { 35 | DEFAULT: 'hsl(var(--secondary))', 36 | foreground: 'hsl(var(--secondary-foreground))', 37 | }, 38 | destructive: { 39 | DEFAULT: 'hsl(var(--destructive))', 40 | foreground: 'hsl(var(--destructive-foreground))', 41 | }, 42 | muted: { 43 | DEFAULT: 'hsl(var(--muted))', 44 | foreground: 'hsl(var(--muted-foreground))', 45 | }, 46 | accent: { 47 | DEFAULT: 'hsl(var(--accent))', 48 | foreground: 'hsl(var(--accent-foreground))', 49 | }, 50 | popover: { 51 | DEFAULT: 'hsl(var(--popover))', 52 | foreground: 'hsl(var(--popover-foreground))', 53 | }, 54 | card: { 55 | DEFAULT: 'hsl(var(--card))', 56 | foreground: 'hsl(var(--card-foreground))', 57 | }, 58 | }, 59 | borderRadius: { 60 | lg: 'var(--radius)', 61 | md: 'calc(var(--radius) - 2px)', 62 | sm: 'calc(var(--radius) - 4px)', 63 | }, 64 | keyframes: { 65 | 'accordion-down': { 66 | from: { height: '0' }, 67 | to: { height: 'var(--radix-accordion-content-height)' }, 68 | }, 69 | 'accordion-up': { 70 | from: { height: 'var(--radix-accordion-content-height)' }, 71 | to: { height: '0' }, 72 | }, 73 | }, 74 | animation: { 75 | 'accordion-down': 'accordion-down 0.2s ease-out', 76 | 'accordion-up': 'accordion-up 0.2s ease-out', 77 | }, 78 | }, 79 | }, 80 | plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')], 81 | } satisfies Config; 82 | 83 | export default withUt(config); 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ultimate Next.js 15, Typescript and Supabase Course: Build a Full-Featured Slack Clone 2 | 3 | Welcome to the Ultimate Next.js 15, TypeScript and Supabase Course! In this course, you'll learn how to build a fully-featured Slack clone from scratch. This README will guide you through setting up the project and running the course. 4 | 5 | ## Prerequisites 6 | 7 | Before you begin, make sure you have the following installed: 8 | 9 | - Node.js (v14 or later) 10 | - Bun (latest version) or you can use npm or what works for you 11 | - Git 12 | 13 | ## Getting Started 14 | 15 | Follow these steps to set up the project: 16 | 17 | 1. **Clone the Repository:** 18 | 19 | ```bash 20 | git clone https://github.com/laribright/slack-clone.git 21 | cd slack-clone 22 | ``` 23 | 24 | 2. **Install Dependencies:** 25 | 26 | ```bash 27 | bun install 28 | ``` 29 | 30 | 3. **Set Up Environment Variables:** 31 | 32 | - Rename the `.env.example` file to `.env.local` and fill in the required environment variables. 33 | 34 | ```bash 35 | mv .env.example .env.local 36 | ``` 37 | 38 | 4. **Run the Development Server:** 39 | 40 | ```bash 41 | bun dev 42 | ``` 43 | 44 | Your app should now be running on [http://localhost:3000](http://localhost:3000). 45 | 46 | ## Course Structure 47 | 48 | This course is divided into multiple modules, each covering different aspects of building the Slack clone. The modules include: 49 | 50 | - Setting up Next.js and TypeScript 51 | - Configuring Supabase (RPC, Storage, SQL, Role Level Security) 52 | - Styling with Tailwind CSS and Shadcn UI 53 | - Implementing Authentication (Google Auth, GitHub Auth, Email Auth with Magic Link) 54 | - Managing state with Zustand and React Hook Form 55 | - Real-time communication with Socket.IO 56 | - Handling file uploads with Uploadthing and Supbase Storage 57 | - Advanced Next.js features and middleware 58 | 59 | ## Resources 60 | 61 | ### SQL Scripts 62 | 63 | All SQL scripts used in this course can be found in the `sql.txt` file. These scripts will help you set up your database schema, functions, and other necessary configurations. 64 | 65 | ### Documentation Links 66 | 67 | For detailed documentation and additional resources, refer to the `docs.tsx` file. This file contains links to various documentation pages for the libraries and tools used in this course. 68 | 69 | ### Environment Variables 70 | 71 | Make sure to properly configure your environment variables by referring to the `.env.example` file. This file contains example values and instructions on what needs to be filled in. 72 | 73 | ## Course Video 74 | 75 | Watch the full course on YouTube: [Ultimate Next.js 15, Typescript and Supabase Course: Build a Full-Featured Slack Clone](https://youtu.be/3D8Q_BMurfs) 76 | 77 | Part 2 78 | Watch the full course on YouTube: [Ultimate Next.js 15, Typescript and Supabase Course: Build a Full-Featured Slack Clone](https://youtu.be/LX3zttE15s4) 79 | 80 | ## Community and Support 81 | 82 | If you have any questions, need further help, or want to engage with other learners, join our Discord group. Here, you can get personalized mentorship, ask questions, and share your progress. 83 | 84 | ## Contributing 85 | 86 | If you find any issues or have suggestions for improvements, feel free to open an issue or submit a pull request. Contributions are always welcome! 87 | 88 | ## Support 89 | 90 | If you find this course helpful, please give this repository a star to show your support! 91 | 92 | --- 93 | 94 | Happy coding! Let's build something amazing together. 95 | -------------------------------------------------------------------------------- /src/actions/channels.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { supabaseServerClient } from '@/supabase/supabaseServer'; 4 | import { getUserData } from './get-user-data'; 5 | 6 | export const createChannel = async ({ 7 | name, 8 | workspaceId, 9 | userId, 10 | }: { 11 | workspaceId: string; 12 | name: string; 13 | userId: string; 14 | }) => { 15 | const supabase = await supabaseServerClient(); 16 | const userData = await getUserData(); 17 | 18 | if (!userData) { 19 | return { error: 'No user data' }; 20 | } 21 | 22 | const { error, data: channelRecord } = await supabase 23 | .from('channels') 24 | .insert({ 25 | name, 26 | user_id: userId, 27 | workspace_id: workspaceId, 28 | }) 29 | .select('*'); 30 | 31 | if (error) { 32 | return { error: 'Insert Error' }; 33 | } 34 | 35 | // Update channel members array 36 | const [, updateChannelMembersError] = await updateChannelMembers( 37 | channelRecord[0].id, 38 | userId 39 | ); 40 | 41 | if (updateChannelMembersError) { 42 | return { error: 'Update members channel error' }; 43 | } 44 | 45 | // Add channel to user's channels array 46 | const [, addChannelToUserError] = await addChannelToUser( 47 | userData.id, 48 | channelRecord[0].id 49 | ); 50 | 51 | if (addChannelToUserError) { 52 | return { error: 'Add channel to user error' }; 53 | } 54 | 55 | // Add channel to workspace's channels array 56 | const [, updateWorkspaceChannelError] = await updateWorkspaceChannel( 57 | channelRecord[0].id, 58 | workspaceId 59 | ); 60 | 61 | if (updateWorkspaceChannelError) { 62 | return { error: 'Update workspace channel error' }; 63 | } 64 | }; 65 | 66 | export const addChannelToUser = async (userId: string, channelId: string) => { 67 | const supabase = await supabaseServerClient(); 68 | 69 | const { data: addChannelData, error: addChannelError } = await supabase.rpc( 70 | 'update_user_channels', 71 | { 72 | user_id: userId, 73 | channel_id: channelId, 74 | } 75 | ); 76 | 77 | return [addChannelData, addChannelError]; 78 | }; 79 | 80 | export const updateChannelMembers = async ( 81 | channelId: string, 82 | userId: string 83 | ) => { 84 | const supabase = await supabaseServerClient(); 85 | 86 | const { data: updateChannelData, error: updateChannelError } = 87 | await supabase.rpc('update_channel_members', { 88 | new_member: userId, 89 | channel_id: channelId, 90 | }); 91 | 92 | return [updateChannelData, updateChannelError]; 93 | }; 94 | 95 | const updateWorkspaceChannel = async ( 96 | channelId: string, 97 | workspaceId: string 98 | ) => { 99 | const supabase = await supabaseServerClient(); 100 | 101 | const { data: updateWorkspaceData, error: updateWorkspaceError } = 102 | await supabase.rpc('add_channel_to_workspace', { 103 | channel_id: channelId, 104 | workspace_id: workspaceId, 105 | }); 106 | 107 | return [updateWorkspaceData, updateWorkspaceError]; 108 | }; 109 | 110 | export const updateChannelRegulators = async ( 111 | userId: string, 112 | channelId: string 113 | ) => { 114 | const supabase = await supabaseServerClient(); 115 | 116 | const { data: updateChannelData, error: updateChannelError } = 117 | await supabase.rpc('update_channel_regulators', { 118 | new_regulator: userId, 119 | channel_id: channelId, 120 | }); 121 | 122 | return [updateChannelData, updateChannelError]; 123 | }; 124 | -------------------------------------------------------------------------------- /src/components/create-channel-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, FC, SetStateAction, useState } from 'react'; 2 | import { z } from 'zod'; 3 | import { useForm } from 'react-hook-form'; 4 | import { zodResolver } from '@hookform/resolvers/zod'; 5 | import { toast } from 'sonner'; 6 | import { useRouter } from 'next/navigation'; 7 | 8 | import { 9 | Dialog, 10 | DialogContent, 11 | DialogHeader, 12 | DialogTitle, 13 | } from '@/components/ui/dialog'; 14 | import Typography from '@/components/ui/typography'; 15 | import { 16 | Form, 17 | FormControl, 18 | FormDescription, 19 | FormField, 20 | FormItem, 21 | FormLabel, 22 | FormMessage, 23 | } from '@/components/ui/form'; 24 | import { Input } from '@/components/ui/input'; 25 | import { Button } from '@/components/ui/button'; 26 | import { createChannel } from '@/actions/channels'; 27 | 28 | const CreateChannelDialog: FC<{ 29 | dialogOpen: boolean; 30 | setDialogOpen: Dispatch>; 31 | workspaceId: string; 32 | userId: string; 33 | }> = ({ dialogOpen, setDialogOpen, userId, workspaceId }) => { 34 | const [isSubmitting, setIsSubmitting] = useState(false); 35 | const router = useRouter(); 36 | 37 | const formSchema = z.object({ 38 | name: z 39 | .string() 40 | .min(2, { message: 'Channel name must be at least 2 characters long' }), 41 | }); 42 | 43 | const form = useForm>({ 44 | resolver: zodResolver(formSchema), 45 | defaultValues: { 46 | name: '', 47 | }, 48 | }); 49 | 50 | const onSubmit = async ({ name }: z.infer) => { 51 | try { 52 | setIsSubmitting(true); 53 | 54 | await createChannel({ 55 | name, 56 | userId, 57 | workspaceId, 58 | }); 59 | 60 | router.refresh(); 61 | setIsSubmitting(false); 62 | setDialogOpen(false); 63 | form.reset(); 64 | toast.success('Channel created successfully'); 65 | } catch (error) { 66 | setIsSubmitting(false); 67 | } 68 | }; 69 | 70 | return ( 71 | setDialogOpen(prevState => !prevState)} 74 | > 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
83 | 84 | ( 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 101 | 102 | 103 | 104 | )} 105 | /> 106 | 107 | 110 | 111 | 112 |
113 |
114 | ); 115 | }; 116 | 117 | export default CreateChannelDialog; 118 | -------------------------------------------------------------------------------- /src/pages/api/web-socket/messages/[messageId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from 'next'; 2 | 3 | import { SockerIoApiResponse } from '@/types/app'; 4 | import { getUserDataPages } from '@/actions/get-user-data'; 5 | import supabaseServerClientPages from '@/supabase/supabaseSeverPages'; 6 | import { SupabaseClient } from '@supabase/supabase-js'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: SockerIoApiResponse 11 | ) { 12 | if (!['DELETE', 'PATCH'].includes(req.method!)) { 13 | return res.status(405).json({ error: 'Method not allowed' }); 14 | } 15 | 16 | try { 17 | const userData = await getUserDataPages(req, res); 18 | 19 | if (!userData) { 20 | return res.status(401).json({ error: 'Unauthorized' }); 21 | } 22 | 23 | const { messageId, channelId, workspaceId } = req.query as Record< 24 | string, 25 | string 26 | >; 27 | 28 | if (!messageId || !channelId || !workspaceId) { 29 | return res.status(400).json({ error: 'Invalid request' }); 30 | } 31 | 32 | const { content } = req.body; 33 | 34 | const supabase = supabaseServerClientPages(req, res); 35 | 36 | const { data: messageData, error } = await supabase 37 | .from('messages') 38 | .select('*, user: user_id (*)') 39 | .eq('id', messageId) 40 | .single(); 41 | 42 | if (error || !messageData) { 43 | return res.status(404).json({ error: 'Message not found' }); 44 | } 45 | 46 | // type in ('user', 'admin', 'regulator') 47 | const isMessageOwner = messageData.user_id === userData.id; 48 | const isAdmin = userData.type === 'admin'; 49 | const isRegulator = userData.type === 'regulator'; 50 | 51 | const canEditMessage = isMessageOwner || !messageData.is_deleted; 52 | 53 | if (!canEditMessage) { 54 | return res.status(403).json({ error: 'Forbidden' }); 55 | } 56 | 57 | if (req.method === 'PATCH') { 58 | if (!isMessageOwner) { 59 | return res.status(403).json({ error: 'Forbidden' }); 60 | } 61 | 62 | await updateMessageContent(supabase, messageId, content); 63 | } else if (req.method === 'DELETE') { 64 | await deleteMessaeg(supabase, messageId); 65 | } 66 | 67 | const { data: updatedMessage, error: messageError } = await supabase 68 | .from('messages') 69 | .select('*, user: user_id (*)') 70 | .order('created_at', { ascending: true }) 71 | .eq('id', messageId) 72 | .single(); 73 | 74 | if (messageError || !updatedMessage) { 75 | return res.status(404).json({ error: 'Message not found' }); 76 | } 77 | 78 | res?.socket?.server?.io?.emit( 79 | `channel:${channelId}:channel-messages:update`, 80 | updatedMessage 81 | ); 82 | return res.status(200).json({ message: updatedMessage }); 83 | } catch (error) { 84 | console.log('MESSAGE ID ERROR', error); 85 | return res.status(500).json({ error: 'Internal Server Error' }); 86 | } 87 | } 88 | 89 | async function updateMessageContent( 90 | supabase: SupabaseClient, 91 | messageId: string, 92 | content: string 93 | ) { 94 | await supabase 95 | .from('messages') 96 | .update({ 97 | content, 98 | updated_at: new Date().toISOString(), 99 | }) 100 | .eq('id', messageId) 101 | .select('*, user: user_id (*)') 102 | .single(); 103 | } 104 | 105 | async function deleteMessaeg(supabase: SupabaseClient, messageId: string) { 106 | await supabase 107 | .from('messages') 108 | .update({ 109 | content: 'This message has been deleted', 110 | file_url: null, 111 | is_deleted: true, 112 | }) 113 | .eq('id', messageId) 114 | .select('*, user: user_id (*)') 115 | .single(); 116 | } 117 | -------------------------------------------------------------------------------- /src/components/menu-bar.tsx: -------------------------------------------------------------------------------- 1 | import { Editor } from '@tiptap/react'; 2 | import { 3 | Bold, 4 | Code, 5 | Italic, 6 | List, 7 | ListOrdered, 8 | SquareCode, 9 | Strikethrough, 10 | } from 'lucide-react'; 11 | import { useTheme } from 'next-themes'; 12 | import data from '@emoji-mart/data'; 13 | import Picker from '@emoji-mart/react'; 14 | import { BsEmojiSmile } from 'react-icons/bs'; 15 | 16 | import Typography from '@/components/ui/typography'; 17 | import { 18 | Popover, 19 | PopoverContent, 20 | PopoverTrigger, 21 | } from '@/components/ui/popover'; 22 | 23 | const MenuBar = ({ editor }: { editor: Editor }) => { 24 | const { resolvedTheme } = useTheme(); 25 | 26 | return ( 27 |
28 | 35 | 42 | 49 | 50 | 58 | 66 | 67 | 74 | 82 | 83 | 84 | 85 | 88 | 89 | 90 | 94 | editor.chain().focus().insertContent(emoji.native).run() 95 | } 96 | /> 97 | 98 | 99 |
100 | ); 101 | }; 102 | 103 | export default MenuBar; 104 | -------------------------------------------------------------------------------- /sql.txt: -------------------------------------------------------------------------------- 1 | create table 2 | users ( 3 | id uuid primary key references auth.users (id) not null, 4 | email text unique not null, 5 | name text, 6 | type text default 'user' check ( 7 | type in ('user', 'admin', 'regulator') 8 | ), 9 | avatar_url text not null, 10 | created_at timestamp default current_timestamp, 11 | is_away boolean default false not null, 12 | phone text, 13 | workplaces text[], 14 | channels text[] 15 | ); 16 | 17 | alter table users enable row level security; 18 | 19 | create policy "Can view own user data." on users for 20 | select 21 | using (auth.uid () = id); 22 | 23 | create policy "Can update own user data." on users 24 | for update 25 | using (auth.uid () = id); 26 | 27 | create 28 | or replace function public.handle_new_user () returns trigger as $$ 29 | begin 30 | if new.raw_user_meta_data->>'avatar_url' is null or new.raw_user_meta_data->>'avatar_url' = '' then 31 | new.raw_user_meta_data = jsonb_set(new.raw_user_meta_data, '{avatar_url}', '"https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"' ::jsonb); 32 | end if; 33 | insert into public.users (id, name, type, email, avatar_url) 34 | values (new.id, new.raw_user_meta_data->>'full_name', 'user', new.email, new.raw_user_meta_data->>'avatar_url'); 35 | return new; 36 | end; 37 | $$ language plpgsql security definer; 38 | 39 | create 40 | or replace trigger on_auth_user_created 41 | after insert on auth.users for each row 42 | execute procedure public.handle_new_user (); 43 | 44 | 45 | 46 | ADD USER TO WORKSPACE 47 | create 48 | or replace function add_workspace_to_user (user_id uuid, new_workspace text) returns void as $$ 49 | BEGIN 50 | update users set workspaces = workspaces || array[new_workspace] 51 | where id = user_id; 52 | END; 53 | $$ language plpgsql; 54 | 55 | CREATE CHANNELS 56 | create table 57 | channels ( 58 | id uuid primary key default gen_random_uuid () not null, 59 | name text not null, 60 | workspace_id uuid references public.workspaces (id) not null, 61 | user_id uuid references public.users (id) not null, 62 | members text[], 63 | regulators text[] 64 | ); 65 | 66 | alter table channels enable row level security; 67 | 68 | create policy "Can view own user data." on channels for 69 | select 70 | using (auth.uid () = user_id); 71 | 72 | create policy "Can update own user data." on channels 73 | for update 74 | using (auth.uid () = user_id); 75 | 76 | create policy "Can insert own user data." on channels for insert 77 | with 78 | check (auth.uid () = user_id); 79 | 80 | 81 | UPDATE USER CHANNELS 82 | create 83 | or replace function update_user_channels (user_id uuid, channel_id text) returns void as $$ 84 | BEGIN 85 | update users set channels = channels || array[channel_id] 86 | where id = user_id; 87 | END; 88 | $$ language plpgsql; 89 | 90 | UPDATE CHANNEL MEMBERS 91 | create 92 | or replace function update_channel_members (new_member text, channel_id uuid) returns void as $$ 93 | BEGIN 94 | update channels set members = members || array[new_member] 95 | where id = channel_id; 96 | END; 97 | $$ language plpgsql; 98 | 99 | 100 | UPDATE WORKSPACE CHANNEL 101 | create 102 | or replace function add_channel_to_workspace (channel_id text, workspace_id uuid) returns void as $$ 103 | begin 104 | update workspaces set channels = channels || array[channel_id] 105 | where id = workspace_id; 106 | end; 107 | $$ language plpgsql; 108 | 109 | UPDATE CHANNEL REGULATORS 110 | create 111 | or replace function update_channel_regulators (new_regulator text, channel_id uuid) returns void as $$ 112 | BEGIN 113 | update channels set regulators = regulators || array[new_regulator] 114 | where id = channel_id; 115 | END; 116 | $$ language plpgsql; 117 | -------------------------------------------------------------------------------- /src/components/chat-messages.tsx: -------------------------------------------------------------------------------- 1 | import { ElementRef, FC, useRef } from 'react'; 2 | import { format } from 'date-fns'; 3 | 4 | import { Channel, User, Workspace } from '@/types/app'; 5 | import { useChatFetcher } from '@/hooks/use-chat-fetcher'; 6 | import DotAnimatedLoader from '@/components/dot-animated-loader'; 7 | import ChatItem from '@/components/chat-item'; 8 | import { useChatSocketConnection } from '@/hooks/use-chat-socket-connection'; 9 | import IntroBanner from '@/components/intro-banner'; 10 | import { Button } from '@/components/ui/button'; 11 | import { useChatScrollHandler } from '@/hooks/use-chat-scroll-handler'; 12 | 13 | const DATE_FORMAT = 'd MMM yyy, HH:mm'; 14 | 15 | type ChatMessagesProps = { 16 | userData: User; 17 | name: string; 18 | chatId: string; 19 | apiUrl: string; 20 | socketUrl: string; 21 | socketQuery: Record; 22 | paramKey: 'channelId' | 'recipientId'; 23 | paramValue: string; 24 | type: 'Channel' | 'DirectMessage'; 25 | workspaceData: Workspace; 26 | channelData?: Channel; 27 | }; 28 | 29 | const ChatMessages: FC = ({ 30 | apiUrl, 31 | chatId, 32 | name, 33 | paramKey, 34 | paramValue, 35 | socketQuery, 36 | socketUrl, 37 | type, 38 | userData, 39 | workspaceData, 40 | channelData, 41 | }) => { 42 | const chatRef = useRef>(null); 43 | const bottomRef = useRef>(null); 44 | 45 | const queryKey = 46 | type === 'Channel' ? `channel:${chatId}` : `direct_message:${chatId}`; 47 | 48 | const { data, status, fetchNextPage, hasNextPage, isFetchingNextPage } = 49 | useChatFetcher({ 50 | apiUrl, 51 | queryKey, 52 | pageSize: 10, 53 | paramKey, 54 | paramValue, 55 | }); 56 | 57 | useChatSocketConnection({ 58 | queryKey, 59 | addKey: 60 | type === 'Channel' 61 | ? `${queryKey}:channel-messages` 62 | : `direct_messages:post`, 63 | updateKey: 64 | type === 'Channel' 65 | ? `${queryKey}:channel-messaegs:update` 66 | : `direct_messages:update`, 67 | paramValue, 68 | }); 69 | 70 | useChatScrollHandler({ 71 | chatRef, 72 | bottomRef, 73 | count: data?.pages?.[0].data?.length ?? 0, 74 | }); 75 | 76 | if (status === 'pending') { 77 | return ; 78 | } 79 | 80 | if (status === 'error') { 81 | return
Error Occured
; 82 | } 83 | 84 | const renderMessages = () => 85 | data.pages.map(page => 86 | page.data.map(message => ( 87 | 101 | )) 102 | ); 103 | 104 | return ( 105 |
106 | {!hasNextPage && ( 107 | 112 | )} 113 | {hasNextPage && ( 114 |
115 | {isFetchingNextPage ? ( 116 | 117 | ) : ( 118 | 121 | )} 122 |
123 | )} 124 |
{renderMessages()}
125 |
126 |
127 | ); 128 | }; 129 | 130 | export default ChatMessages; 131 | -------------------------------------------------------------------------------- /src/components/create-workspace.tsx: -------------------------------------------------------------------------------- 1 | import { FaPlus } from 'react-icons/fa6'; 2 | import slugify from 'slugify'; 3 | import { z } from 'zod'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { toast } from 'sonner'; 6 | import { useRouter } from 'next/navigation'; 7 | 8 | import { Button } from '@/components/ui/button'; 9 | import Typography from '@/components/ui/typography'; 10 | import { 11 | Dialog, 12 | DialogContent, 13 | DialogHeader, 14 | DialogTitle, 15 | DialogTrigger, 16 | } from '@/components/ui/dialog'; 17 | import { 18 | Form, 19 | FormControl, 20 | FormDescription, 21 | FormField, 22 | FormItem, 23 | FormLabel, 24 | FormMessage, 25 | } from '@/components/ui/form'; 26 | import { useForm } from 'react-hook-form'; 27 | import { zodResolver } from '@hookform/resolvers/zod'; 28 | import { Input } from '@/components/ui/input'; 29 | import ImageUpload from '@/components/image-upload'; 30 | import { createWorkspace } from '@/actions/create-workspace'; 31 | import { useCreateWorkspaceValues } from '@/hooks/create-workspace-values'; 32 | import { useState } from 'react'; 33 | 34 | const CreateWorkspace = () => { 35 | const router = useRouter(); 36 | const { imageUrl, updateImageUrl } = useCreateWorkspaceValues(); 37 | const [isOpen, setIsOpen] = useState(false); 38 | const [isSubmitting, setIsSubmitting] = useState(false); 39 | 40 | const formSchema = z.object({ 41 | name: z.string().min(2, { 42 | message: 'Workspace name should be at least 2 characters long', 43 | }), 44 | }); 45 | 46 | const form = useForm>({ 47 | resolver: zodResolver(formSchema), 48 | defaultValues: { 49 | name: '', 50 | }, 51 | }); 52 | 53 | async function onSubmit({ name }: z.infer) { 54 | const slug = slugify(name, { lower: true }); 55 | const invite_code = uuidv4(); 56 | setIsSubmitting(true); 57 | 58 | const result = await createWorkspace({ name, slug, invite_code, imageUrl }); 59 | 60 | setIsSubmitting(false); 61 | 62 | if (result?.error) { 63 | console.error(result.error); 64 | } 65 | 66 | form.reset(); 67 | updateImageUrl(''); 68 | setIsOpen(false); 69 | router.refresh(); 70 | toast.success('Workspace created successfully'); 71 | } 72 | 73 | return ( 74 | setIsOpen(prevValue => !prevValue)} 77 | > 78 | 79 |
80 | 83 | 84 |
85 |
86 | 87 | 88 | 89 | 90 | 91 | 92 |
93 | 94 | ( 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 110 | 111 | 112 | 113 | )} 114 | /> 115 | 116 | 117 | 118 | 121 | 122 | 123 |
124 |
125 | ); 126 | }; 127 | 128 | export default CreateWorkspace; 129 | -------------------------------------------------------------------------------- /src/components/chat-group.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { FC, useEffect, useState } from 'react'; 4 | import { useSearchParams } from 'next/navigation'; 5 | 6 | import Sidebar from '@/components/sidebar'; 7 | import { Channel, User, Workspace } from '@/types/app'; 8 | import InfoSection from '@/components/info-section'; 9 | import ChatHeader from '@/components/chat-header'; 10 | import TextEditor from '@/components/text-editor'; 11 | import ChatMessages from '@/components/chat-messages'; 12 | import SearchBar from '@/components/search-bar'; 13 | import VideoChat from './video-chat'; 14 | 15 | type ChatGroupProps = { 16 | type: 'Channel' | 'DirectMessage'; 17 | socketUrl: string; 18 | apiUrl: string; 19 | headerTitle: string; 20 | chatId: string; 21 | socketQuery: Record; 22 | paramKey: 'channelId' | 'recipientId'; 23 | paramValue: string; 24 | userData: User; 25 | currentWorkspaceData: Workspace; 26 | currentChannelData: Channel | undefined; 27 | userWorkspaceData: Workspace[]; 28 | userWorkspaceChannels: Channel[]; 29 | slug: string; 30 | }; 31 | 32 | const ChatGroup: FC = ({ 33 | apiUrl, 34 | chatId, 35 | headerTitle, 36 | paramKey, 37 | paramValue, 38 | socketQuery, 39 | socketUrl, 40 | type, 41 | currentChannelData, 42 | currentWorkspaceData, 43 | slug, 44 | userData, 45 | userWorkspaceChannels, 46 | userWorkspaceData, 47 | }) => { 48 | const [isVideoCall, setIsVideoCall] = useState(false); 49 | const searchParams = useSearchParams(); 50 | 51 | useEffect(() => { 52 | const callParam = searchParams?.get('call'); 53 | setIsVideoCall(callParam === 'true'); 54 | }, [searchParams, chatId]); 55 | 56 | return ( 57 | <> 58 |
59 | 64 | 72 | 77 |
78 | 79 | 80 |
81 | {!isVideoCall && ( 82 | 95 | )} 96 | {isVideoCall && ( 97 | 101 | )} 102 |
103 |
104 |
105 |
106 | {!isVideoCall && ( 107 | 115 | )} 116 |
117 | 118 | ); 119 | }; 120 | 121 | export default ChatGroup; 122 | -------------------------------------------------------------------------------- /src/components/text-editor.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { FiPlus } from 'react-icons/fi'; 4 | import { EditorContent, useEditor } from '@tiptap/react'; 5 | import StarterKit from '@tiptap/starter-kit'; 6 | import { FC, useState } from 'react'; 7 | import PlaceHolder from '@tiptap/extension-placeholder'; 8 | import { Send } from 'lucide-react'; 9 | import axios from 'axios'; 10 | 11 | import { Button } from '@/components/ui/button'; 12 | import MenuBar from '@/components/menu-bar'; 13 | import { Channel, User, Workspace } from '@/types/app'; 14 | import { 15 | Dialog, 16 | DialogContent, 17 | DialogDescription, 18 | DialogHeader, 19 | } from '@/components/ui/dialog'; 20 | import { DialogTitle } from '@radix-ui/react-dialog'; 21 | import ChatFileUpload from '@/components/chat-file-upload'; 22 | 23 | type TextEditorProps = { 24 | apiUrl: string; 25 | type: 'Channel' | 'DirectMessage'; 26 | channel?: Channel; 27 | workspaceData: Workspace; 28 | userData: User; 29 | recipientId?: string; 30 | }; 31 | 32 | const TextEditor: FC = ({ 33 | apiUrl, 34 | type, 35 | channel, 36 | workspaceData, 37 | userData, 38 | recipientId, 39 | }) => { 40 | const [content, setContent] = useState(''); 41 | const [fileUploadModal, setFileUploadModal] = useState(false); 42 | 43 | const toggleFileUploadModal = () => 44 | setFileUploadModal(prevState => !prevState); 45 | 46 | const editor = useEditor({ 47 | extensions: [ 48 | StarterKit, 49 | PlaceHolder.configure({ 50 | placeholder: `Message #${channel?.name ?? 'USERNAME'}`, 51 | }), 52 | ], 53 | autofocus: true, 54 | content, 55 | onUpdate({ editor }) { 56 | setContent(editor.getHTML()); 57 | }, 58 | }); 59 | 60 | const handleSend = async () => { 61 | if (content.length < 2) return; 62 | 63 | try { 64 | const payload = { 65 | content, 66 | type, 67 | }; 68 | 69 | let endpoint = apiUrl; 70 | 71 | if (type === 'Channel' && channel) { 72 | endpoint += `?channelId=${channel.id}&workspaceId=${workspaceData.id}`; 73 | } else if (type === 'DirectMessage' && recipientId) { 74 | endpoint += `?recipientId=${recipientId}&workspaceId=${workspaceData.id}`; 75 | } 76 | 77 | await axios.post(endpoint, payload); 78 | 79 | setContent(''); 80 | editor?.commands.setContent(''); 81 | } catch (error) { 82 | console.log(error); 83 | } 84 | }; 85 | 86 | return ( 87 |
88 |
89 | {editor && } 90 |
91 |
92 | 96 |
97 |
98 | 103 |
104 | 105 | 113 | 114 | 115 | 116 | 117 | File Upload 118 | 119 | Upload a file to share with your team 120 | 121 | 122 | 123 | 130 | 131 | 132 |
133 | ); 134 | }; 135 | 136 | export default TextEditor; 137 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/pages/api/web-socket/direct-messages/[messageId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from 'next'; 2 | 3 | import { SockerIoApiResponse } from '@/types/app'; 4 | import { getUserDataPages } from '@/actions/get-user-data'; 5 | import supabaseServerClientPages from '@/supabase/supabaseSeverPages'; 6 | import { SupabaseClient } from '@supabase/supabase-js'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: SockerIoApiResponse 11 | ) { 12 | if (!['DELETE', 'PATCH'].includes(req.method!)) { 13 | return res.status(405).json({ error: 'Method not allowed' }); 14 | } 15 | try { 16 | const userData = await getUserDataPages(req, res); 17 | 18 | if (!userData) { 19 | return res.status(401).json({ error: 'Unauthorized' }); 20 | } 21 | 22 | const { messageId } = req.query; 23 | const { content } = req.body; 24 | 25 | if (!messageId) { 26 | return res.status(400).json({ error: 'Invalid request' }); 27 | } 28 | 29 | const supabase = supabaseServerClientPages(req, res); 30 | 31 | const { data: messageData, error } = await supabase 32 | .from('direct_messages') 33 | .select( 34 | ` 35 | *, 36 | user_one:users!direct_messages_user_one_fkey(*), 37 | user_two:users!direct_messages_user_two_fkey(*) 38 | ` 39 | ) 40 | .eq('id', messageId) 41 | .single(); 42 | 43 | console.log('DIRECT MESSAGE messageData: ', error); 44 | console.log('DIRECT MESSAGE messageData: ', messageData); 45 | 46 | if (error || !messageData) { 47 | console.log('DIRECT MESSAGE ERROR: ', error); 48 | return res.status(404).json({ error: 'Message not found' }); 49 | } 50 | 51 | const isMessageOwner = 52 | userData.id === messageData.user_one.id || 53 | userData.id === messageData.user_two.id; 54 | const isAdmin = userData.type === 'admin'; 55 | const isRegulator = userData.type === 'regulator'; 56 | 57 | const canEditMessage = 58 | isMessageOwner || isAdmin || isRegulator || !messageData.is_deleted; 59 | 60 | if (!canEditMessage) { 61 | console.log('DIRECT MESSAGE ERROR: canEditMessage:', error); 62 | return res.status(403).json({ error: 'Forbidden' }); 63 | } 64 | 65 | if (req.method === 'PATCH') { 66 | if (!isMessageOwner) { 67 | return res.status(403).json({ error: 'Forbidden' }); 68 | } 69 | 70 | await updateMessageContent(supabase, messageId as string, content); 71 | } else if (req.method === 'DELETE') { 72 | await deleteMessage(supabase, messageId as string); 73 | } 74 | 75 | const { data: updatedMessage, error: messageError } = await supabase 76 | .from('direct_messages') 77 | .select( 78 | ` 79 | *, 80 | user_one:users!direct_messages_user_one_fkey(*), 81 | user_two:users!direct_messages_user_two_fkey(*), 82 | user:users!direct_messages_user_fkey(*) 83 | ` 84 | ) 85 | .eq('id', messageId) 86 | .single(); 87 | 88 | if (messageError || !updatedMessage) { 89 | console.log('DIRECT MESSAGE ERROR: ', messageError); 90 | return res.status(404).json({ error: 'Message not found' }); 91 | } 92 | 93 | res?.socket?.server?.io?.emit('direct-message:update', updatedMessage); 94 | return res.status(200).json({ message: updatedMessage }); 95 | } catch (error) { 96 | console.log('DIRECT MESSAGE ERROR: ', error); 97 | return res.status(500).json({ error: 'Error sending message' }); 98 | } 99 | } 100 | 101 | async function updateMessageContent( 102 | supabase: SupabaseClient, 103 | messageId: string, 104 | content: string 105 | ) { 106 | await supabase 107 | .from('direct_messages') 108 | .update({ 109 | content, 110 | updated_at: new Date().toISOString(), 111 | }) 112 | .eq('id', messageId) 113 | .select( 114 | `*, 115 | user_one:users!direct_messages_user_one_fkey(*), 116 | user_two:users!direct_messages_user_two_fkey(*) 117 | ` 118 | ) 119 | .single(); 120 | } 121 | 122 | async function deleteMessage(supabase: SupabaseClient, messageId: string) { 123 | await supabase 124 | .from('direct_messages') 125 | .update({ 126 | content: 'This message has been deleted', 127 | file_url: null, 128 | is_deleted: true, 129 | }) 130 | .eq('id', messageId) 131 | .select( 132 | `*, 133 | user_one:users!direct_messages_user_one_fkey(*), 134 | user_two:users!direct_messages_user_two_fkey(*) 135 | ` 136 | ) 137 | .single(); 138 | } 139 | -------------------------------------------------------------------------------- /src/components/preferences-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTheme } from 'next-themes'; 4 | import { HiOutlinePaintBrush } from 'react-icons/hi2'; 5 | 6 | import { useColorPrefrences } from '@/providers/color-prefrences'; 7 | import { Dialog, DialogContent, DialogTitle, DialogTrigger } from './ui/dialog'; 8 | import Typography from './ui/typography'; 9 | import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; 10 | import { Button } from './ui/button'; 11 | import { cn } from '@/lib/utils'; 12 | import { MdLightMode } from 'react-icons/md'; 13 | import { BsLaptop } from 'react-icons/bs'; 14 | 15 | const PreferencesDialog = () => { 16 | const { setTheme, theme } = useTheme(); 17 | const { selectColor } = useColorPrefrences(); 18 | 19 | return ( 20 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 47 | 52 |
53 | 63 | 73 | 83 |
84 |
85 | 90 | 91 |
92 | 99 | 106 | 113 |
114 |
115 |
116 |
117 |
118 | ); 119 | }; 120 | 121 | export default PreferencesDialog; 122 | -------------------------------------------------------------------------------- /src/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 |