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

238 lines
8.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.
//
// `term` is the LAST WORD being typed, not the whole textarea — a natural
// sentence like "cho tôi thuốc parace" has no drug alias containing that
// full phrase, so matching against it always returns empty (found live
// 2026-08-10: this silently killed every suggestion once a user typed a
// sentence instead of a bare drug name, which is the more common case).
useEffect(() => {
const words = value.trim().split(/\s+/);
const term = words[words.length - 1] ?? "";
if (term.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
// Race guard: a slower request for an earlier keystroke must not
// overwrite a faster one for a later keystroke — found live 2026-08-10
// typing fast could show a stale, unrelated suggestion list because the
// fetch callback set state unconditionally regardless of whether `term`
// (and therefore `value`) had already changed by the time it resolved.
let cancelled = false;
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/suggest?q=${encodeURIComponent(term)}`);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
if (cancelled) return;
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())
);
if (cancelled) return;
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} catch {
if (!cancelled) {
setSuggestions([]);
setShowSuggestions(false);
}
}
}, 200);
return () => {
cancelled = true;
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>
);
}