index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { useState } from 'react'
  2. import { Fragment } from 'react/jsx-runtime'
  3. import { format } from 'date-fns'
  4. import {
  5. ArrowLeft,
  6. MoreVertical,
  7. Edit,
  8. Paperclip,
  9. Phone,
  10. ImagePlus,
  11. Plus,
  12. Search as SearchIcon,
  13. Send,
  14. Video,
  15. MessagesSquare,
  16. } from 'lucide-react'
  17. import { cn, getDisplayNameInitials } from '@/lib/utils'
  18. import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
  19. import { Button } from '@/components/ui/button'
  20. import { ScrollArea } from '@/components/ui/scroll-area'
  21. import { Separator } from '@/components/ui/separator'
  22. import { ConfigDrawer } from '@/components/config-drawer'
  23. import { Header } from '@/components/layout/header'
  24. import { Main } from '@/components/layout/main'
  25. import { ProfileDropdown } from '@/components/profile-dropdown'
  26. import { Search } from '@/components/search'
  27. import { ThemeSwitch } from '@/components/theme-switch'
  28. import { NewChat } from './components/new-chat'
  29. import { type ChatUser, type Convo } from './data/chat-types'
  30. // Fake Data
  31. import { conversations } from './data/convo.json'
  32. export function Chats() {
  33. const [search, setSearch] = useState('')
  34. const [selectedUser, setSelectedUser] = useState<ChatUser | null>(null)
  35. const [mobileSelectedUser, setMobileSelectedUser] = useState<ChatUser | null>(
  36. null
  37. )
  38. const [createConversationDialogOpened, setCreateConversationDialog] =
  39. useState(false)
  40. // Filtered data based on the search query
  41. const filteredChatList = conversations.filter(({ fullName }) =>
  42. fullName.toLowerCase().includes(search.trim().toLowerCase())
  43. )
  44. const currentMessage = selectedUser?.messages.reduce(
  45. (acc: Record<string, Convo[]>, obj) => {
  46. const key = format(obj.timestamp, 'd MMM, yyyy')
  47. // Create an array for the category if it doesn't exist
  48. if (!acc[key]) {
  49. acc[key] = []
  50. }
  51. // Push the current object to the array
  52. acc[key].push(obj)
  53. return acc
  54. },
  55. {}
  56. )
  57. const users = conversations.map(({ messages, ...user }) => user)
  58. return (
  59. <>
  60. {/* ===== Top Heading ===== */}
  61. <Header>
  62. <Search className='me-auto' />
  63. <ThemeSwitch />
  64. <ConfigDrawer />
  65. <ProfileDropdown />
  66. </Header>
  67. <Main fixed>
  68. <section className='flex h-full gap-6'>
  69. {/* Left Side */}
  70. <div className='flex w-full flex-col gap-2 sm:w-56 lg:w-72 2xl:w-80'>
  71. <div className='sticky top-0 z-10 -mx-4 bg-background px-4 pb-3 shadow-md sm:static sm:z-auto sm:mx-0 sm:p-0 sm:shadow-none'>
  72. <div className='flex items-center justify-between py-2'>
  73. <div className='flex gap-2'>
  74. <h1 className='text-2xl font-bold'>Inbox</h1>
  75. <MessagesSquare size={20} />
  76. </div>
  77. <Button
  78. size='icon'
  79. variant='ghost'
  80. onClick={() => setCreateConversationDialog(true)}
  81. className='rounded-lg'
  82. >
  83. <Edit size={24} className='stroke-muted-foreground' />
  84. </Button>
  85. </div>
  86. <label
  87. className={cn(
  88. 'focus-within:ring-1 focus-within:ring-ring focus-within:outline-hidden',
  89. 'flex h-10 w-full items-center space-x-0 rounded-md border border-border ps-2'
  90. )}
  91. >
  92. <SearchIcon size={15} className='me-2 stroke-slate-500' />
  93. <span className='sr-only'>Search</span>
  94. <input
  95. type='text'
  96. className='w-full flex-1 bg-inherit text-sm focus-visible:outline-hidden'
  97. placeholder='Search chat...'
  98. value={search}
  99. onChange={(e) => setSearch(e.target.value)}
  100. />
  101. </label>
  102. </div>
  103. <ScrollArea className='-mx-3 h-full overflow-scroll p-3'>
  104. {filteredChatList.map((chatUsr) => {
  105. const { id, profile, username, messages, fullName } = chatUsr
  106. const lastConvo = messages[0]
  107. const lastMsg =
  108. lastConvo.sender === 'You'
  109. ? `You: ${lastConvo.message}`
  110. : lastConvo.message
  111. return (
  112. <Fragment key={id}>
  113. <button
  114. type='button'
  115. className={cn(
  116. 'group hover:bg-accent hover:text-accent-foreground',
  117. `flex w-full rounded-md px-2 py-2 text-start text-sm`,
  118. selectedUser?.id === id && 'sm:bg-muted'
  119. )}
  120. onClick={() => {
  121. setSelectedUser(chatUsr)
  122. setMobileSelectedUser(chatUsr)
  123. }}
  124. >
  125. <div className='flex gap-2'>
  126. <Avatar>
  127. <AvatarImage src={profile} alt={username} />
  128. <AvatarFallback>
  129. {getDisplayNameInitials(fullName)}
  130. </AvatarFallback>
  131. </Avatar>
  132. <div>
  133. <span className='col-start-2 row-span-2 font-medium'>
  134. {fullName}
  135. </span>
  136. <span className='col-start-2 row-span-2 row-start-2 line-clamp-2 text-ellipsis text-muted-foreground group-hover:text-accent-foreground/90'>
  137. {lastMsg}
  138. </span>
  139. </div>
  140. </div>
  141. </button>
  142. <Separator className='my-1' />
  143. </Fragment>
  144. )
  145. })}
  146. </ScrollArea>
  147. </div>
  148. {/* Right Side */}
  149. {selectedUser ? (
  150. <div
  151. className={cn(
  152. 'absolute inset-0 start-full z-50 hidden w-full flex-1 flex-col border bg-background shadow-xs sm:static sm:z-auto sm:flex sm:rounded-md',
  153. mobileSelectedUser && 'inset-s-0 flex'
  154. )}
  155. >
  156. {/* Top Part */}
  157. <div className='mb-1 flex flex-none justify-between bg-card p-4 shadow-lg sm:rounded-t-md'>
  158. {/* Left */}
  159. <div className='flex gap-3'>
  160. <Button
  161. size='icon'
  162. variant='ghost'
  163. className='-ms-2 h-full sm:hidden'
  164. onClick={() => setMobileSelectedUser(null)}
  165. >
  166. <ArrowLeft className='rtl:rotate-180' />
  167. </Button>
  168. <div className='flex items-center gap-2 lg:gap-4'>
  169. <Avatar className='size-9 lg:size-11'>
  170. <AvatarImage
  171. src={selectedUser.profile}
  172. alt={selectedUser.username}
  173. />
  174. <AvatarFallback>
  175. {getDisplayNameInitials(selectedUser.fullName)}
  176. </AvatarFallback>
  177. </Avatar>
  178. <div>
  179. <span className='col-start-2 row-span-2 text-sm font-medium lg:text-base'>
  180. {selectedUser.fullName}
  181. </span>
  182. <span className='col-start-2 row-span-2 row-start-2 line-clamp-1 block max-w-32 text-xs text-nowrap text-ellipsis text-muted-foreground lg:max-w-none lg:text-sm'>
  183. {selectedUser.title}
  184. </span>
  185. </div>
  186. </div>
  187. </div>
  188. {/* Right */}
  189. <div className='-me-1 flex items-center gap-1 lg:gap-2'>
  190. <Button
  191. size='icon'
  192. variant='ghost'
  193. className='hidden size-8 rounded-full sm:inline-flex lg:size-10'
  194. >
  195. <Video size={22} className='stroke-muted-foreground' />
  196. </Button>
  197. <Button
  198. size='icon'
  199. variant='ghost'
  200. className='hidden size-8 rounded-full sm:inline-flex lg:size-10'
  201. >
  202. <Phone size={22} className='stroke-muted-foreground' />
  203. </Button>
  204. <Button
  205. size='icon'
  206. variant='ghost'
  207. className='h-10 rounded-md sm:h-8 sm:w-4 lg:h-10 lg:w-6'
  208. >
  209. <MoreVertical className='stroke-muted-foreground sm:size-5' />
  210. </Button>
  211. </div>
  212. </div>
  213. {/* Conversation */}
  214. <div className='flex flex-1 flex-col gap-2 rounded-md px-4 pt-0 pb-4'>
  215. <div className='flex size-full flex-1'>
  216. <div className='chat-text-container relative -me-4 flex flex-1 flex-col overflow-y-hidden'>
  217. <div className='chat-flex flex h-40 w-full grow flex-col-reverse justify-start gap-4 overflow-y-auto py-2 pe-4 pb-4'>
  218. {currentMessage &&
  219. Object.keys(currentMessage).map((key) => (
  220. <Fragment key={key}>
  221. {currentMessage[key].map((msg, index) => (
  222. <div
  223. key={`${msg.sender}-${msg.timestamp}-${index}`}
  224. className={cn(
  225. 'chat-box max-w-72 px-3 py-2 wrap-break-word shadow-lg',
  226. msg.sender === 'You'
  227. ? 'self-end rounded-[16px_16px_0_16px] bg-primary/90 text-primary-foreground/75'
  228. : 'self-start rounded-[16px_16px_16px_0] bg-muted'
  229. )}
  230. >
  231. {msg.message}{' '}
  232. <span
  233. className={cn(
  234. 'mt-1 block text-xs font-light text-foreground/75 italic',
  235. msg.sender === 'You' &&
  236. 'text-end text-primary-foreground/85'
  237. )}
  238. >
  239. {format(msg.timestamp, 'h:mm a')}
  240. </span>
  241. </div>
  242. ))}
  243. <div className='text-center text-xs'>{key}</div>
  244. </Fragment>
  245. ))}
  246. </div>
  247. </div>
  248. </div>
  249. <form className='flex w-full flex-none gap-2'>
  250. <div className='flex flex-1 items-center gap-2 rounded-md border border-input bg-card px-2 py-1 focus-within:ring-1 focus-within:ring-ring focus-within:outline-hidden lg:gap-4'>
  251. <div className='space-x-1'>
  252. <Button
  253. size='icon'
  254. type='button'
  255. variant='ghost'
  256. className='h-8 rounded-md'
  257. >
  258. <Plus size={20} className='stroke-muted-foreground' />
  259. </Button>
  260. <Button
  261. size='icon'
  262. type='button'
  263. variant='ghost'
  264. className='hidden h-8 rounded-md lg:inline-flex'
  265. >
  266. <ImagePlus
  267. size={20}
  268. className='stroke-muted-foreground'
  269. />
  270. </Button>
  271. <Button
  272. size='icon'
  273. type='button'
  274. variant='ghost'
  275. className='hidden h-8 rounded-md lg:inline-flex'
  276. >
  277. <Paperclip
  278. size={20}
  279. className='stroke-muted-foreground'
  280. />
  281. </Button>
  282. </div>
  283. <label className='flex-1'>
  284. <span className='sr-only'>Chat Text Box</span>
  285. <input
  286. type='text'
  287. placeholder='Type your messages...'
  288. className='h-8 w-full bg-inherit focus-visible:outline-hidden'
  289. />
  290. </label>
  291. <Button
  292. variant='ghost'
  293. size='icon'
  294. className='hidden sm:inline-flex'
  295. >
  296. <Send size={20} />
  297. </Button>
  298. </div>
  299. <Button className='h-full sm:hidden'>
  300. <Send size={18} /> Send
  301. </Button>
  302. </form>
  303. </div>
  304. </div>
  305. ) : (
  306. <div
  307. className={cn(
  308. 'absolute inset-0 start-full z-50 hidden w-full flex-1 flex-col justify-center rounded-md border bg-card shadow-xs sm:static sm:z-auto sm:flex'
  309. )}
  310. >
  311. <div className='flex flex-col items-center space-y-6'>
  312. <div className='flex size-16 items-center justify-center rounded-full border-2 border-border'>
  313. <MessagesSquare className='size-8' />
  314. </div>
  315. <div className='space-y-2 text-center'>
  316. <h1 className='text-xl font-semibold'>Your messages</h1>
  317. <p className='text-sm text-muted-foreground'>
  318. Send a message to start a chat.
  319. </p>
  320. </div>
  321. <Button onClick={() => setCreateConversationDialog(true)}>
  322. Send message
  323. </Button>
  324. </div>
  325. </div>
  326. )}
  327. </section>
  328. <NewChat
  329. users={users}
  330. onOpenChange={setCreateConversationDialog}
  331. open={createConversationDialogOpened}
  332. />
  333. </Main>
  334. </>
  335. )
  336. }