Răsfoiți Sursa

feat: add new chat dialog in chats page (#90)

* feat: create conversation dialog

* chore: format codes

* chore: format code

* chore: update ts-unused-vars rule in eslint

* refactor: extract chat types into a file

* refactor: rename and update position of new-chat

* fix: update text color

---------

Co-authored-by: satnaing <satnaingdev@gmail.com>
ali akrem barka 1 an în urmă
părinte
comite
93be36314d

+ 13 - 0
eslint.config.js

@@ -29,6 +29,19 @@ export default tseslint.config(
         { allowConstantExport: true },
       ],
       'no-console': 'error',
+      'no-unused-vars': 'off',
+      '@typescript-eslint/no-unused-vars': [
+        'error',
+        {
+          args: 'all',
+          argsIgnorePattern: '^_',
+          caughtErrors: 'all',
+          caughtErrorsIgnorePattern: '^_',
+          destructuredArrayIgnorePattern: '^_',
+          varsIgnorePattern: '^_',
+          ignoreRestSiblings: true,
+        },
+      ],
     },
   }
 )

+ 138 - 0
src/features/chats/components/new-chat.tsx

@@ -0,0 +1,138 @@
+import { useEffect, useState } from 'react'
+import { IconCheck, IconX } from '@tabler/icons-react'
+import { toast } from '@/hooks/use-toast'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { ChatUser } from '../data/chat-types'
+
+type User = Omit<ChatUser, 'messages'>
+
+type Props = {
+  users: User[]
+  open: boolean
+  onOpenChange: (open: boolean) => void
+}
+export function NewChat({ users, onOpenChange, open }: Props) {
+  const [selectedUsers, setSelectedUsers] = useState<User[]>([])
+
+  const handleSelectUser = (user: User) => {
+    if (!selectedUsers.find((u) => u.id === user.id)) {
+      setSelectedUsers([...selectedUsers, user])
+    } else {
+      handleRemoveUser(user.id)
+    }
+  }
+
+  const handleRemoveUser = (userId: string) => {
+    setSelectedUsers(selectedUsers.filter((user) => user.id !== userId))
+  }
+
+  useEffect(() => {
+    if (!open) {
+      setSelectedUsers([])
+    }
+  }, [open])
+
+  const onSubmit = () => {
+    toast({
+      title: 'You submitted the following values:',
+      description: (
+        <pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
+          <code className='text-white'>
+            {JSON.stringify(selectedUsers, null, 2)}
+          </code>
+        </pre>
+      ),
+    })
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='sm:max-w-[600px]'>
+        <DialogHeader>
+          <DialogTitle>New message</DialogTitle>
+        </DialogHeader>
+        <div className='flex flex-col gap-4'>
+          <div className='flex flex-wrap items-center gap-2'>
+            <span className='text-sm text-zinc-400'>To:</span>
+            {selectedUsers.map((user) => (
+              <Badge key={user.id} variant='default'>
+                {user.fullName}
+                <button
+                  className='ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2'
+                  onKeyDown={(e) => {
+                    if (e.key === 'Enter') {
+                      handleRemoveUser(user.id)
+                    }
+                  }}
+                  onClick={() => handleRemoveUser(user.id)}
+                >
+                  <IconX className='h-3 w-3 text-muted-foreground hover:text-foreground' />
+                </button>
+              </Badge>
+            ))}
+          </div>
+          <Command className='rounded-lg border'>
+            <CommandInput
+              placeholder='Search people...'
+              className='text-foreground'
+            />
+            <CommandList>
+              <CommandEmpty>No people found.</CommandEmpty>
+              <CommandGroup>
+                {users.map((user) => (
+                  <CommandItem
+                    key={user.id}
+                    onSelect={() => handleSelectUser(user)}
+                    className='flex items-center justify-between gap-2'
+                  >
+                    <div className='flex items-center gap-2'>
+                      <img
+                        src={user.profile || '/placeholder.svg'}
+                        alt={user.fullName}
+                        className='h-8 w-8 rounded-full'
+                      />
+                      <div className='flex flex-col'>
+                        <span className='text-sm font-medium'>
+                          {user.fullName}
+                        </span>
+                        <span className='text-xs text-zinc-400'>
+                          {user.username}
+                        </span>
+                      </div>
+                    </div>
+
+                    {selectedUsers.find((u) => u.id === user.id) && (
+                      <IconCheck className='h-4 w-4' />
+                    )}
+                  </CommandItem>
+                ))}
+              </CommandGroup>
+            </CommandList>
+          </Command>
+          <Button
+            variant={'default'}
+            onClick={onSubmit}
+            disabled={selectedUsers.length === 0}
+          >
+            Chat
+          </Button>
+        </div>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 4 - 0
src/features/chats/data/chat-types.ts

@@ -0,0 +1,4 @@
+import { conversations } from './convo.json'
+
+export type ChatUser = (typeof conversations)[number]
+export type Convo = ChatUser['messages'][number]

+ 183 - 141
src/features/chats/index.tsx

@@ -24,25 +24,26 @@ import { Main } from '@/components/layout/main'
 import { ProfileDropdown } from '@/components/profile-dropdown'
 import { Search } from '@/components/search'
 import { ThemeSwitch } from '@/components/theme-switch'
+import { NewChat } from './components/new-chat'
+import { type ChatUser, type Convo } from './data/chat-types'
 // Fake Data
 import { conversations } from './data/convo.json'
 
-type ChatUser = (typeof conversations)[number]
-type Convo = ChatUser['messages'][number]
-
 export default function Chats() {
   const [search, setSearch] = useState('')
-  const [selectedUser, setSelectedUser] = useState<ChatUser>(conversations[0])
+  const [selectedUser, setSelectedUser] = useState<ChatUser | null>(null)
   const [mobileSelectedUser, setMobileSelectedUser] = useState<ChatUser | null>(
     null
   )
+  const [createConversationDialogOpened, setCreateConversationDialog] =
+    useState(false)
 
   // Filtered data based on the search query
   const filteredChatList = conversations.filter(({ fullName }) =>
     fullName.toLowerCase().includes(search.trim().toLowerCase())
   )
 
-  const currentMessage = selectedUser.messages.reduce(
+  const currentMessage = selectedUser?.messages.reduce(
     (acc: Record<string, Convo[]>, obj) => {
       const key = format(obj.timestamp, 'd MMM, yyyy')
 
@@ -59,6 +60,8 @@ export default function Chats() {
     {}
   )
 
+  const users = conversations.map(({ messages, ...user }) => user)
+
   return (
     <>
       {/* ===== Top Heading ===== */}
@@ -81,7 +84,12 @@ export default function Chats() {
                   <IconMessages size={20} />
                 </div>
 
-                <Button size='icon' variant='ghost' className='rounded-lg'>
+                <Button
+                  size='icon'
+                  variant='ghost'
+                  onClick={() => setCreateConversationDialog(true)}
+                  className='rounded-lg'
+                >
                   <IconEdit size={24} className='stroke-muted-foreground' />
                 </Button>
               </div>
@@ -113,7 +121,7 @@ export default function Chats() {
                       type='button'
                       className={cn(
                         `-mx-1 flex w-full rounded-md px-2 py-2 text-left text-sm hover:bg-secondary/75`,
-                        selectedUser.id === id && 'sm:bg-muted'
+                        selectedUser?.id === id && 'sm:bg-muted'
                       )}
                       onClick={() => {
                         setSelectedUser(chatUsr)
@@ -143,161 +151,195 @@ export default function Chats() {
           </div>
 
           {/* Right Side */}
-          <div
-            className={cn(
-              'absolute inset-0 left-full z-50 hidden w-full flex-1 flex-col rounded-md border bg-primary-foreground shadow-sm transition-all duration-200 sm:static sm:z-auto sm:flex',
-              mobileSelectedUser && 'left-0 flex'
-            )}
-          >
-            {/* Top Part */}
-            <div className='mb-1 flex flex-none justify-between rounded-t-md bg-secondary p-4 shadow-lg'>
-              {/* Left */}
-              <div className='flex gap-3'>
-                <Button
-                  size='icon'
-                  variant='ghost'
-                  className='-ml-2 h-full sm:hidden'
-                  onClick={() => setMobileSelectedUser(null)}
-                >
-                  <IconArrowLeft />
-                </Button>
-                <div className='flex items-center gap-2 lg:gap-4'>
-                  <Avatar className='size-9 lg:size-11'>
-                    <AvatarImage
-                      src={selectedUser.profile}
-                      alt={selectedUser.username}
-                    />
-                    <AvatarFallback>{selectedUser.username}</AvatarFallback>
-                  </Avatar>
-                  <div>
-                    <span className='col-start-2 row-span-2 text-sm font-medium lg:text-base'>
-                      {selectedUser.fullName}
-                    </span>
-                    <span className='col-start-2 row-span-2 row-start-2 line-clamp-1 block max-w-32 text-ellipsis text-nowrap text-xs text-muted-foreground lg:max-w-none lg:text-sm'>
-                      {selectedUser.title}
-                    </span>
+          {selectedUser ? (
+            <div
+              className={cn(
+                'absolute inset-0 left-full z-50 hidden w-full flex-1 flex-col rounded-md border bg-primary-foreground shadow-sm transition-all duration-200 sm:static sm:z-auto sm:flex',
+                mobileSelectedUser && 'left-0 flex'
+              )}
+            >
+              {/* Top Part */}
+              <div className='mb-1 flex flex-none justify-between rounded-t-md bg-secondary p-4 shadow-lg'>
+                {/* Left */}
+                <div className='flex gap-3'>
+                  <Button
+                    size='icon'
+                    variant='ghost'
+                    className='-ml-2 h-full sm:hidden'
+                    onClick={() => setMobileSelectedUser(null)}
+                  >
+                    <IconArrowLeft />
+                  </Button>
+                  <div className='flex items-center gap-2 lg:gap-4'>
+                    <Avatar className='size-9 lg:size-11'>
+                      <AvatarImage
+                        src={selectedUser.profile}
+                        alt={selectedUser.username}
+                      />
+                      <AvatarFallback>{selectedUser.username}</AvatarFallback>
+                    </Avatar>
+                    <div>
+                      <span className='col-start-2 row-span-2 text-sm font-medium lg:text-base'>
+                        {selectedUser.fullName}
+                      </span>
+                      <span className='col-start-2 row-span-2 row-start-2 line-clamp-1 block max-w-32 text-ellipsis text-nowrap text-xs text-muted-foreground lg:max-w-none lg:text-sm'>
+                        {selectedUser.title}
+                      </span>
+                    </div>
                   </div>
                 </div>
-              </div>
 
-              {/* Right */}
-              <div className='-mr-1 flex items-center gap-1 lg:gap-2'>
-                <Button
-                  size='icon'
-                  variant='ghost'
-                  className='hidden size-8 rounded-full sm:inline-flex lg:size-10'
-                >
-                  <IconVideo size={22} className='stroke-muted-foreground' />
-                </Button>
-                <Button
-                  size='icon'
-                  variant='ghost'
-                  className='hidden size-8 rounded-full sm:inline-flex lg:size-10'
-                >
-                  <IconPhone size={22} className='stroke-muted-foreground' />
-                </Button>
-                <Button
-                  size='icon'
-                  variant='ghost'
-                  className='h-10 rounded-md sm:h-8 sm:w-4 lg:h-10 lg:w-6'
-                >
-                  <IconDotsVertical className='stroke-muted-foreground sm:size-5' />
-                </Button>
+                {/* Right */}
+                <div className='-mr-1 flex items-center gap-1 lg:gap-2'>
+                  <Button
+                    size='icon'
+                    variant='ghost'
+                    className='hidden size-8 rounded-full sm:inline-flex lg:size-10'
+                  >
+                    <IconVideo size={22} className='stroke-muted-foreground' />
+                  </Button>
+                  <Button
+                    size='icon'
+                    variant='ghost'
+                    className='hidden size-8 rounded-full sm:inline-flex lg:size-10'
+                  >
+                    <IconPhone size={22} className='stroke-muted-foreground' />
+                  </Button>
+                  <Button
+                    size='icon'
+                    variant='ghost'
+                    className='h-10 rounded-md sm:h-8 sm:w-4 lg:h-10 lg:w-6'
+                  >
+                    <IconDotsVertical className='stroke-muted-foreground sm:size-5' />
+                  </Button>
+                </div>
               </div>
-            </div>
 
-            {/* Conversation */}
-            <div className='flex flex-1 flex-col gap-2 rounded-md px-4 pb-4 pt-0'>
-              <div className='flex size-full flex-1'>
-                <div className='chat-text-container relative -mr-4 flex flex-1 flex-col overflow-y-hidden'>
-                  <div className='chat-flex flex h-40 w-full flex-grow flex-col-reverse justify-start gap-4 overflow-y-auto py-2 pb-4 pr-4'>
-                    {currentMessage &&
-                      Object.keys(currentMessage).map((key) => (
-                        <Fragment key={key}>
-                          {currentMessage[key].map((msg, index) => (
-                            <div
-                              key={`${msg.sender}-${msg.timestamp}-${index}`}
-                              className={cn(
-                                'chat-box max-w-72 break-words px-3 py-2 shadow-lg',
-                                msg.sender === 'You'
-                                  ? 'self-end rounded-[16px_16px_0_16px] bg-primary/85 text-primary-foreground/75'
-                                  : 'self-start rounded-[16px_16px_16px_0] bg-secondary'
-                              )}
-                            >
-                              {msg.message}{' '}
-                              <span
+              {/* Conversation */}
+              <div className='flex flex-1 flex-col gap-2 rounded-md px-4 pb-4 pt-0'>
+                <div className='flex size-full flex-1'>
+                  <div className='chat-text-container relative -mr-4 flex flex-1 flex-col overflow-y-hidden'>
+                    <div className='chat-flex flex h-40 w-full flex-grow flex-col-reverse justify-start gap-4 overflow-y-auto py-2 pb-4 pr-4'>
+                      {currentMessage &&
+                        Object.keys(currentMessage).map((key) => (
+                          <Fragment key={key}>
+                            {currentMessage[key].map((msg, index) => (
+                              <div
+                                key={`${msg.sender}-${msg.timestamp}-${index}`}
                                 className={cn(
-                                  'mt-1 block text-xs font-light italic text-muted-foreground',
-                                  msg.sender === 'You' && 'text-right'
+                                  'chat-box max-w-72 break-words px-3 py-2 shadow-lg',
+                                  msg.sender === 'You'
+                                    ? 'self-end rounded-[16px_16px_0_16px] bg-primary/85 text-primary-foreground/75'
+                                    : 'self-start rounded-[16px_16px_16px_0] bg-secondary'
                                 )}
                               >
-                                {format(msg.timestamp, 'h:mm a')}
-                              </span>
-                            </div>
-                          ))}
-                          <div className='text-center text-xs'>{key}</div>
-                        </Fragment>
-                      ))}
+                                {msg.message}{' '}
+                                <span
+                                  className={cn(
+                                    'mt-1 block text-xs font-light italic text-muted-foreground',
+                                    msg.sender === 'You' && 'text-right'
+                                  )}
+                                >
+                                  {format(msg.timestamp, 'h:mm a')}
+                                </span>
+                              </div>
+                            ))}
+                            <div className='text-center text-xs'>{key}</div>
+                          </Fragment>
+                        ))}
+                    </div>
                   </div>
                 </div>
-              </div>
-              <form className='flex w-full flex-none gap-2'>
-                <div className='flex flex-1 items-center gap-2 rounded-md border border-input px-2 py-1 focus-within:outline-none focus-within:ring-1 focus-within:ring-ring lg:gap-4'>
-                  <div className='space-x-1'>
-                    <Button
-                      size='icon'
-                      type='button'
-                      variant='ghost'
-                      className='h-8 rounded-md'
-                    >
-                      <IconPlus size={20} className='stroke-muted-foreground' />
-                    </Button>
-                    <Button
-                      size='icon'
-                      type='button'
-                      variant='ghost'
-                      className='hidden h-8 rounded-md lg:inline-flex'
-                    >
-                      <IconPhotoPlus
-                        size={20}
-                        className='stroke-muted-foreground'
+                <form className='flex w-full flex-none gap-2'>
+                  <div className='flex flex-1 items-center gap-2 rounded-md border border-input px-2 py-1 focus-within:outline-none focus-within:ring-1 focus-within:ring-ring lg:gap-4'>
+                    <div className='space-x-1'>
+                      <Button
+                        size='icon'
+                        type='button'
+                        variant='ghost'
+                        className='h-8 rounded-md'
+                      >
+                        <IconPlus
+                          size={20}
+                          className='stroke-muted-foreground'
+                        />
+                      </Button>
+                      <Button
+                        size='icon'
+                        type='button'
+                        variant='ghost'
+                        className='hidden h-8 rounded-md lg:inline-flex'
+                      >
+                        <IconPhotoPlus
+                          size={20}
+                          className='stroke-muted-foreground'
+                        />
+                      </Button>
+                      <Button
+                        size='icon'
+                        type='button'
+                        variant='ghost'
+                        className='hidden h-8 rounded-md lg:inline-flex'
+                      >
+                        <IconPaperclip
+                          size={20}
+                          className='stroke-muted-foreground'
+                        />
+                      </Button>
+                    </div>
+                    <label className='flex-1'>
+                      <span className='sr-only'>Chat Text Box</span>
+                      <input
+                        type='text'
+                        placeholder='Type your messages...'
+                        className='h-8 w-full bg-inherit focus-visible:outline-none'
                       />
-                    </Button>
+                    </label>
                     <Button
-                      size='icon'
-                      type='button'
                       variant='ghost'
-                      className='hidden h-8 rounded-md lg:inline-flex'
+                      size='icon'
+                      className='hidden sm:inline-flex'
                     >
-                      <IconPaperclip
-                        size={20}
-                        className='stroke-muted-foreground'
-                      />
+                      <IconSend size={20} />
                     </Button>
                   </div>
-                  <label className='flex-1'>
-                    <span className='sr-only'>Chat Text Box</span>
-                    <input
-                      type='text'
-                      placeholder='Type your messages...'
-                      className='h-8 w-full bg-inherit focus-visible:outline-none'
-                    />
-                  </label>
-                  <Button
-                    variant='ghost'
-                    size='icon'
-                    className='hidden sm:inline-flex'
-                  >
-                    <IconSend size={20} />
+                  <Button className='h-full sm:hidden'>
+                    <IconSend size={18} /> Send
                   </Button>
+                </form>
+              </div>
+            </div>
+          ) : (
+            <div
+              className={cn(
+                'absolute inset-0 left-full z-50 hidden w-full flex-1 flex-col justify-center rounded-md border bg-primary-foreground shadow-sm transition-all duration-200 sm:static sm:z-auto sm:flex'
+              )}
+            >
+              <div className='flex flex-col items-center space-y-6'>
+                <div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-white'>
+                  <IconMessages className='h-8 w-8' />
+                </div>
+                <div className='space-y-2 text-center'>
+                  <h1 className='text-xl font-semibold'>Your messages</h1>
+                  <p className='text-sm text-gray-400'>
+                    Send a message to start a chat.
+                  </p>
                 </div>
-                <Button className='h-full sm:hidden'>
-                  <IconSend size={18} /> Send
+                <Button
+                  className='bg-blue-500 px-6 text-white hover:bg-blue-600'
+                  onClick={() => setCreateConversationDialog(true)}
+                >
+                  Send message
                 </Button>
-              </form>
+              </div>
             </div>
-          </div>
+          )}
         </section>
+        <NewChat
+          users={users}
+          onOpenChange={setCreateConversationDialog}
+          open={createConversationDialogOpened}
+        />
       </Main>
     </>
   )