zpsajst's picture
Initial commit with environment variables for API keys
2398be6
raw
history blame
12.1 kB
"use client"
import * as React from "react"
import { useState, useEffect, useRef } from "react";
import { Lightbulb, Mic, Globe, Paperclip, Send } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
const PLACEHOLDERS = [
"Generate website with HextaUI",
"Create a new project with Next.js",
"What is the meaning of life?",
"What is the best way to learn React?",
"How to cook a delicious meal?",
"Summarize this article",
];
interface AIChatInputProps {
onSubmit?: (message: string) => void;
onFocus?: () => void;
autoFocus?: boolean;
}
const AIChatInput = ({ onSubmit, onFocus, autoFocus = false }: AIChatInputProps = {}) => {
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const [showPlaceholder, setShowPlaceholder] = useState(true);
const [isActive, setIsActive] = useState(false);
const [thinkActive, setThinkActive] = useState(false);
const [deepSearchActive, setDeepSearchActive] = useState(false);
const [inputValue, setInputValue] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Cycle placeholder text when input is inactive
useEffect(() => {
if (isActive || inputValue) return;
const interval = setInterval(() => {
setShowPlaceholder(false);
setTimeout(() => {
setPlaceholderIndex((prev) => (prev + 1) % PLACEHOLDERS.length);
setShowPlaceholder(true);
}, 400);
}, 3000);
return () => clearInterval(interval);
}, [isActive, inputValue]);
// Close input when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
if (!inputValue) setIsActive(false);
}
};
document.addEventListener("mousedown", handleClickOutside as EventListener);
return () => document.removeEventListener("mousedown", handleClickOutside as EventListener);
}, [inputValue]);
const handleActivate = () => setIsActive(true);
// notify parent when input is focused/activated
const handleActivateWithCallback = () => {
setIsActive(true);
if (onFocus) onFocus();
};
// auto-focus when requested
useEffect(() => {
if (autoFocus && inputRef.current) {
try {
inputRef.current.focus();
} catch (e) {}
}
}, [autoFocus]);
const handleSubmit = () => {
if (inputValue.trim() && onSubmit) {
onSubmit(inputValue.trim());
setInputValue("");
setIsActive(false);
}
};
// small ripple effect for send button
const createRipple = (e: React.MouseEvent) => {
const button = e.currentTarget as HTMLButtonElement;
const circle = document.createElement('span');
const diameter = Math.max(button.clientWidth, button.clientHeight);
const radius = diameter / 2;
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${e.nativeEvent.offsetX - radius}px`;
circle.style.top = `${e.nativeEvent.offsetY - radius}px`;
circle.className = 'ls-ripple';
const ripple = button.getElementsByClassName('ls-ripple')[0];
if (ripple) ripple.remove();
button.appendChild(circle);
setTimeout(() => circle.remove(), 500);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const containerVariants: any = {
collapsed: {
height: 68,
boxShadow: "0 2px 8px 0 rgba(0,0,0,0.08)",
transition: { type: "spring", stiffness: 120, damping: 18 },
},
expanded: {
height: 128,
boxShadow: "0 8px 32px 0 rgba(0,0,0,0.16)",
transition: { type: "spring", stiffness: 120, damping: 18 },
},
};
const placeholderContainerVariants: any = {
initial: {},
animate: { transition: { staggerChildren: 0.025 } },
exit: { transition: { staggerChildren: 0.015, staggerDirection: -1 } },
};
const letterVariants: any = {
initial: {
opacity: 0,
filter: "blur(12px)",
y: 10,
},
animate: {
opacity: 1,
filter: "blur(0px)",
y: 0,
transition: {
opacity: { duration: 0.25 },
filter: { duration: 0.4 },
y: { type: "spring", stiffness: 80, damping: 20 },
},
},
exit: {
opacity: 0,
filter: "blur(12px)",
y: -10,
transition: {
opacity: { duration: 0.2 },
filter: { duration: 0.3 },
y: { type: "spring", stiffness: 80, damping: 20 },
},
},
};
return (
<div className="w-full">
<motion.div
ref={wrapperRef}
className="w-full max-w-3xl mx-auto"
variants={containerVariants}
animate={isActive || inputValue ? "expanded" : "collapsed"}
initial="collapsed"
style={{ overflow: "hidden", borderRadius: 32, background: "#fff" }}
onClick={handleActivateWithCallback}
>
<div className="flex flex-col items-stretch w-full h-full">
{/* Input Row */}
<div className="flex items-center gap-2 p-2 md:p-3 rounded-full bg-white w-full shadow-sm md:shadow-md transition-shadow" aria-hidden={false}>
<button
className="p-2 md:p-3 rounded-full hover:bg-gray-100 transition shrink-0"
title="Attach file"
type="button"
tabIndex={-1}
aria-label="Attach file"
>
<Paperclip size={18} className="md:w-5 md:h-5" />
</button>
{/* Text Input & Placeholder */}
<div className="relative flex-1 min-w-0">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 border-0 outline-0 rounded-md py-2 text-sm md:text-base bg-transparent w-full font-normal relative z-10"
onFocus={handleActivateWithCallback}
ref={inputRef}
autoComplete="off"
aria-label="Message input"
aria-describedby="ls-chat-input-hint"
/>
<div className="absolute left-0 top-0 w-full h-full pointer-events-none flex items-center px-3 py-2 z-0">
<AnimatePresence mode="wait">
{showPlaceholder && !isActive && !inputValue && (
<motion.span
key={placeholderIndex}
className="absolute left-0 top-1/2 -translate-y-1/2 text-gray-400 select-none pointer-events-none text-sm md:text-base"
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
zIndex: 0,
}}
variants={placeholderContainerVariants}
initial="initial"
animate="animate"
exit="exit"
>
{PLACEHOLDERS[placeholderIndex]
.split("")
.map((char, i) => (
<motion.span
key={i}
variants={letterVariants}
style={{ display: "inline-block" }}
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
</motion.span>
)}
</AnimatePresence>
</div>
</div>
<button
className="p-2 md:p-3 rounded-full hover:bg-gray-100 transition shrink-0"
title="Voice input"
type="button"
tabIndex={-1}
>
<Mic size={18} className="md:w-5 md:h-5" />
</button>
<button
className="relative overflow-hidden flex items-center gap-1 bg-black hover:bg-zinc-700 text-white p-2 md:p-3 rounded-full font-medium justify-center shrink-0 shadow"
title="Send"
type="button"
tabIndex={0}
aria-label="Send message"
onClick={(e) => {
createRipple(e as React.MouseEvent);
handleSubmit();
}}
>
<Send size={16} className="md:w-[18px] md:h-[18px]" />
</button>
</div>
{/* Expanded Controls */}
<motion.div
className="w-full flex justify-start px-3 md:px-4 items-center text-xs md:text-sm"
variants={{
hidden: {
opacity: 0,
y: 20,
pointerEvents: "none" as const,
transition: { duration: 0.25 },
},
visible: {
opacity: 1,
y: 0,
pointerEvents: "auto" as const,
transition: { duration: 0.35, delay: 0.08 },
},
}}
initial="hidden"
animate={isActive || inputValue ? "visible" : "hidden"}
style={{ marginTop: 8 }}
>
<div className="flex gap-2 md:gap-3 items-center">
{/* Think Toggle */}
<button
className={`flex items-center gap-1 px-3 md:px-4 py-1.5 md:py-2 rounded-full transition-all font-medium group text-xs md:text-sm ${
thinkActive
? "bg-blue-600/10 outline outline-blue-600/60 text-blue-950"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
title="Think"
type="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setThinkActive((a) => !a);
}}
aria-pressed={thinkActive}
>
<Lightbulb
className="group-hover:fill-yellow-300 transition-all w-4 h-4 md:w-[18px] md:h-[18px]"
/>
Think
</button>
{/* Deep Search Toggle */}
<motion.button
className={`flex items-center px-3 md:px-4 gap-1 py-1.5 md:py-2 rounded-full transition font-medium whitespace-nowrap overflow-hidden justify-start text-xs md:text-sm ${
deepSearchActive
? "bg-blue-600/10 outline outline-blue-600/60 text-blue-950"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
title="Deep Search"
type="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setDeepSearchActive((a) => !a);
}}
initial={false}
animate={{
width: deepSearchActive ? 125 : 36,
paddingLeft: deepSearchActive ? 8 : 9,
}}
aria-pressed={deepSearchActive}
>
<div className="flex-1">
<Globe size={16} className="md:w-[18px] md:h-[18px]" />
</div>
<motion.span
className="pb-0.5"
initial={false}
animate={{
opacity: deepSearchActive ? 1 : 0,
}}
>
Deep Search
</motion.span>
</motion.button>
</div>
</motion.div>
{/* Keyboard hint (visually small, accessible) */}
<div id="ls-chat-input-hint" className="sr-only">
Press Enter to send, Shift+Enter for newline.
</div>
</div>
</motion.div>
</div>
);
};
export { AIChatInput };