Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/chat/ChatDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { useState } from 'react';
import ChatPanel from './ChatPanel';

const ChatDock = () => {
const [isOpen, setIsOpen] = useState(false)
const [isOpen, setIsOpen] = useState<boolean>(false);

return (
<div className="absolute bottom-4 right-4 z-10 flex flex-col items-end gap-4">
{ isOpen && <ChatPanel /> }
{isOpen && <ChatPanel setIsOpen={setIsOpen} />}
<button
className="grid h-14 w-14 place-items-center rounded-xl
bg-slate-950 text-white shadow-md
Expand Down
90 changes: 64 additions & 26 deletions src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,61 @@
import { X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { SubmitEventHandler } from 'react';

import ChatInput from './components/ChatInput';
import ChatHeader from './components/ChatHeader';
import ChatMessageList from './components/ChatMessageList';
import type { ChatMessage } from './types';

interface ChatPanelProps {
setIsOpen: (isOpen: boolean) => void;
}

const ChatPanel = ({ setIsOpen }: ChatPanelProps) => {
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: 1,
sender: 'agent',
text: 'Hi! Ask me anything about this disaster.',
sentAt: new Date(),
},
{
id: 2,
sender: 'user',
text: 'What locations report the highest severity of damage?',
sentAt: new Date(),
},
]);
const [draft, setDraft] = useState('');
const listRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!listRef.current) {
return;
}

listRef.current.scrollTop = listRef.current.scrollHeight;
}, [messages]);

const handleSend: SubmitEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();

const trimmed = draft.trim();
if (!trimmed) {
return;
}

setMessages((current) => [
...current,
{
id: Date.now(),
sender: 'user',
text: trimmed,
sentAt: new Date(),
},
]);
setDraft('');
};

const ChatPanel = () => {
return (
<section
className="w-[360px]
Expand All @@ -9,30 +64,13 @@ const ChatPanel = () => {
animate-rise
overflow-hidden"
>
<header className="flex items-center justify-between
px-5 py-4
border-b border-slate-900/10">
<div>
<p className="font-display text-base font-bold text-slate-900">
Chat
</p>
<p className="text-xs text-slate-500">Ask questions</p>
</div>
<button
className="grid h-8 w-8
place-items-center
rounded-lg
bg-slate-900/10 text-slate-900
transition hover:bg-slate-900/20"
onClick={() => setIsOpen(false)}
>
<X className="h-4 w-4" />
</button>
</header>
<div className="p-4">
{ /* chat bubbles etc */ }
chat stuff here
</div>
<ChatHeader onClose={() => setIsOpen(false)} />
<ChatMessageList messages={messages} listRef={listRef} />
<ChatInput
draft={draft}
onDraftChange={setDraft}
onSend={handleSend}
/>
</section>
);
};
Expand Down
35 changes: 35 additions & 0 deletions src/components/chat/components/ChatBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { formatMessageTime } from '../util';
import type { ChatMessage } from '../types';

interface ChatBubbleProps {
message: ChatMessage;
}

const ChatBubble = ({ message }: ChatBubbleProps) => {
const isUser = message.sender === 'user';

return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[90%] rounded-2xl px-3 py-2 shadow-sm ${
isUser
? 'rounded-br-md bg-slate-900 text-white'
: 'rounded-bl-md border border-slate-200 bg-white text-slate-900'
}`}
>
<p className="text-sm leading-relaxed">{message.text}</p>
<p
className={`mt-1 text-xs ${
isUser
? 'text-slate-300'
: 'text-slate-500'
}`}
>
{formatMessageTime(message.sentAt)}
</p>
</div>
</div>
);
};

export default ChatBubble;
25 changes: 25 additions & 0 deletions src/components/chat/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { X } from 'lucide-react';

interface ChatHeaderProps {
onClose: () => void;
}

const ChatHeader = ({ onClose }: ChatHeaderProps) => {
return (
<header className="flex items-center justify-between px-5 py-4 border-b border-slate-900/10">
<div>
<p className="font-display text-base font-bold text-slate-900">Chat</p>
<p className="text-xs text-slate-500">Ask questions</p>
</div>
<button
className="grid h-8 w-8 place-items-center rounded-lg bg-slate-900/10 text-slate-900 transition hover:bg-slate-900/20"
onClick={onClose}
aria-label="Close chat"
>
<X className="h-4 w-4" />
</button>
</header>
);
};

export default ChatHeader;
59 changes: 59 additions & 0 deletions src/components/chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useLayoutEffect, useRef } from 'react';
import type { SubmitEventHandler } from 'react';
import { Send } from 'lucide-react';

interface ChatInputProps {
draft: string;
onDraftChange: (value: string) => void;
onSend: SubmitEventHandler<HTMLFormElement>;
}

const ChatInput = ({ draft, onDraftChange, onSend }: ChatInputProps) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);

useLayoutEffect(() => {
if (!textareaRef.current) {
return;
}

const maxHeight = 112;
textareaRef.current.style.height = '0px';
const nextHeight = Math.min(textareaRef.current.scrollHeight, maxHeight);
textareaRef.current.style.height = `${nextHeight}px`;
textareaRef.current.style.overflowY = textareaRef.current.scrollHeight > maxHeight ? 'auto' : 'hidden';
}, [draft]);

return (
<form
onSubmit={onSend}
className="border-t border-slate-900/10 bg-white/80 p-3"
>
<div className="flex items-end gap-2 rounded-xl border border-slate-300 bg-white px-3 py-2 focus-within:border-slate-900/40 focus-within:ring-2 focus-within:ring-slate-900/10">
<textarea
ref={textareaRef}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
placeholder="Type your message..."
rows={1}
className="w-full resize-none bg-transparent py-1 text-sm leading-5 text-slate-900 outline-none placeholder:text-slate-400"
/>
<button
type="submit"
disabled={!draft.trim()}
className="grid h-8 w-8 place-items-center rounded-lg bg-slate-900 text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-300"
aria-label="Send message"
>
<Send className="h-4 w-4" />
</button>
</div>
</form>
);
};

export default ChatInput;
24 changes: 24 additions & 0 deletions src/components/chat/components/ChatMessageList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { RefObject } from 'react';

import type { ChatMessage } from '../types';
import ChatBubble from './ChatBubble';

interface ChatMessageListProps {
messages: ChatMessage[];
listRef: RefObject<HTMLDivElement | null>;
}

const ChatMessageList = ({ messages, listRef }: ChatMessageListProps) => {
return (
<div
ref={listRef}
className="flex h-[360px] flex-col gap-3 overflow-y-auto px-4 py-4"
>
{messages.map((message) => (
<ChatBubble key={message.id} message={message} />
))}
</div>
);
};

export default ChatMessageList;
8 changes: 8 additions & 0 deletions src/components/chat/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type Sender = 'user' | 'agent';

export interface ChatMessage {
id: number;
sender: Sender;
text: string;
sentAt: Date;
}
5 changes: 5 additions & 0 deletions src/components/chat/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const formatMessageTime = (date: Date) =>
new Intl.DateTimeFormat([], {
hour: 'numeric',
minute: '2-digit',
}).format(date);