Files
duocthu/apps/web/app/_components/Composer.tsx
T

216 lines
7.6 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from "react";
import { Send, Square, Sparkles, Pill, Search, Command } from "lucide-react";
import { cn } from "@duoc-thu/ui";
interface ComposerProps {
onSubmit: (query: string) => void;
isLoading?: boolean;
onStop?: () => void;
initialValue?: string;
className?: string;
}
const SAMPLE_SUGGESTIONS = [
"Liều dùng Paracetamol người lớn và trẻ em theo cân nặng",
"Chống chỉ định và tác dụng không mong muốn của Amoxicillin",
"Tương tác thuốc giữa Metformin và thuốc cản quang",
"Thận trọng khi dùng Aspirin cho bệnh nhân loét dạ dày",
"Hướng dẫn liều dùng Ibuprofen và giới hạn tối đa ngày",
];
export function Composer({
onSubmit,
isLoading = false,
onStop,
initialValue = "",
className,
}: ComposerProps) {
const [value, setValue] = useState(initialValue);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (initialValue) {
setValue(initialValue);
}
}, [initialValue]);
// Fetch suggestions from API route when query length > 1
useEffect(() => {
const term = value.trim();
if (term.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/suggest?q=${encodeURIComponent(term)}`);
if (res.ok) {
const data = await res.json();
if (data?.suggestions && Array.isArray(data.suggestions) && data.suggestions.length > 0) {
setSuggestions(data.suggestions);
setShowSuggestions(true);
return;
}
}
// Fallback filter local suggestions
const filtered = SAMPLE_SUGGESTIONS.filter((s) =>
s.toLowerCase().includes(term.toLowerCase())
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} catch {
setSuggestions([]);
setShowSuggestions(false);
}
}, 200);
return () => clearTimeout(timer);
}, [value]);
// Close suggestions on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setShowSuggestions(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || isLoading) return;
onSubmit(trimmed);
setValue("");
setSuggestions([]);
setShowSuggestions(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSuggestions && suggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
return;
}
if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
setValue(suggestions[selectedIndex]);
setShowSuggestions(false);
setSelectedIndex(-1);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className={cn("relative w-full max-w-4xl mx-auto", className)}>
{/* Autocomplete Suggestions Dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div
ref={dropdownRef}
className="absolute bottom-full mb-2 left-0 right-0 rounded-2xl border border-border-subtle bg-surface p-2 shadow-elevated backdrop-blur-xl z-40 animate-slide-up"
>
<div className="px-3 py-1 mb-1 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center gap-1">
<Search className="w-3 h-3 text-accent-primary" />
<span>Gợi ý tra cứu Dược thư</span>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{suggestions.map((item, idx) => (
<button
key={idx}
onClick={() => {
setValue(item);
setShowSuggestions(false);
inputRef.current?.focus();
}}
className={cn(
"w-full text-left px-3 py-2 rounded-xl text-xs flex items-center justify-between transition-colors",
selectedIndex === idx
? "bg-accent-soft text-accent-primary font-semibold"
: "text-txt-secondary hover:bg-surface-hover hover:text-txt-primary"
)}
>
<span className="truncate pr-2">{item}</span>
<Pill className="w-3.5 h-3.5 text-accent-primary shrink-0 opacity-70" />
</button>
))}
</div>
</div>
)}
{/* Main Composer Box */}
<div className="relative flex flex-col rounded-3xl border border-border-subtle bg-surface p-2 shadow-surface transition-all focus-within:border-border-accent focus-within:shadow-elevated glass-panel">
<textarea
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nhập tên thuốc hoặc thuộc tính cần tra (Ví dụ: Liều dùng Paracetamol, Chống chỉ định Amoxicillin...)"
rows={2}
className="w-full resize-none bg-transparent px-3 py-2 text-sm text-txt-primary placeholder:text-txt-muted focus:outline-none"
/>
<div className="flex items-center justify-between gap-2 pt-2 px-2 border-t border-border-subtle/50">
<div className="flex items-center gap-1.5 text-[0.7rem] text-txt-muted">
<Command className="w-3 h-3" />
<span className="hidden sm:inline">Nhấn Enter đ gửi Shift+Enter đ xuống dòng</span>
</div>
<div className="flex items-center gap-2">
{isLoading ? (
<button
onClick={onStop}
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-status-danger text-txt-inverse text-xs font-bold shadow-sm hover:opacity-90 transition-opacity"
>
<Square className="w-3.5 h-3.5 fill-current" />
<span>Dừng</span>
</button>
) : (
<button
onClick={handleSubmit}
disabled={!value.trim()}
type="button"
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-xl text-xs font-bold transition-all shadow-sm",
value.trim()
? "bg-accent-primary text-txt-inverse hover:bg-accent-hover"
: "bg-surface-elevated text-txt-muted cursor-not-allowed"
)}
>
<span>Gửi tra cứu</span>
<Send className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
}