|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useState, useEffect, useCallback, useRef } from "react"; |
| 4 | +import { createPortal } from "react-dom"; |
| 5 | +import { Button } from "@/components/ui/button"; |
| 6 | +import { Input } from "@/components/ui/input"; |
| 7 | +import { Languages, ChevronDown, CheckCircle2, Circle, Search } from "lucide-react"; |
| 8 | +import { SUPPORTED_LANGUAGES } from "@/lib/language-utils"; |
| 9 | +import { cn } from "@/lib/utils"; |
| 10 | + |
| 11 | +interface LanguageSelectorProps { |
| 12 | + activeTab: "transcript" | "chat" | "notes"; |
| 13 | + selectedLanguage: string | null; |
| 14 | + isAuthenticated?: boolean; |
| 15 | + onTabSwitch: (tab: "transcript" | "chat" | "notes") => void; |
| 16 | + onLanguageChange?: (languageCode: string | null) => void; |
| 17 | + onRequestSignIn?: () => void; |
| 18 | +} |
| 19 | + |
| 20 | +interface LanguageSelectorMenuProps { |
| 21 | + chevronRef: React.RefObject<HTMLButtonElement | null>; |
| 22 | + menuRef: React.RefObject<HTMLDivElement | null>; |
| 23 | + filteredLanguages: Array<typeof SUPPORTED_LANGUAGES[number]>; |
| 24 | + currentLanguageCode: string; |
| 25 | + selectedLanguage: string | null; |
| 26 | + isAuthenticated: boolean; |
| 27 | + languageSearch: string; |
| 28 | + onLanguageSearchChange: (value: string) => void; |
| 29 | + onLanguageSelect: (langCode: string) => void; |
| 30 | + onRequestSignIn?: () => void; |
| 31 | + onMenuMouseEnter: () => void; |
| 32 | + onMenuMouseLeave: () => void; |
| 33 | +} |
| 34 | + |
| 35 | +export function LanguageSelector({ |
| 36 | + activeTab, |
| 37 | + selectedLanguage, |
| 38 | + isAuthenticated = false, |
| 39 | + onTabSwitch, |
| 40 | + onLanguageChange, |
| 41 | + onRequestSignIn, |
| 42 | +}: LanguageSelectorProps) { |
| 43 | + const [isMenuOpen, setIsMenuOpen] = useState(false); |
| 44 | + const [languageSearch, setLanguageSearch] = useState(""); |
| 45 | + const [isMounted, setIsMounted] = useState(false); |
| 46 | + |
| 47 | + const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); |
| 48 | + const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null); |
| 49 | + const containerRef = useRef<HTMLDivElement>(null); |
| 50 | + const chevronRef = useRef<HTMLButtonElement>(null); |
| 51 | + const menuRef = useRef<HTMLDivElement>(null); |
| 52 | + |
| 53 | + // Get current language - null or 'en' means English |
| 54 | + const currentLanguageCode = selectedLanguage || 'en'; |
| 55 | + |
| 56 | + // Filter languages based on search |
| 57 | + const filteredLanguages = SUPPORTED_LANGUAGES.filter(lang => |
| 58 | + lang.name.toLowerCase().includes(languageSearch.toLowerCase()) || |
| 59 | + lang.nativeName.toLowerCase().includes(languageSearch.toLowerCase()) |
| 60 | + ); |
| 61 | + |
| 62 | + // Track mount state for portal rendering |
| 63 | + useEffect(() => { |
| 64 | + setIsMounted(true); |
| 65 | + }, []); |
| 66 | + |
| 67 | + // Cleanup timeouts on unmount |
| 68 | + useEffect(() => { |
| 69 | + return () => { |
| 70 | + if (hoverTimeoutRef.current) { |
| 71 | + clearTimeout(hoverTimeoutRef.current); |
| 72 | + } |
| 73 | + if (closeTimeoutRef.current) { |
| 74 | + clearTimeout(closeTimeoutRef.current); |
| 75 | + } |
| 76 | + }; |
| 77 | + }, []); |
| 78 | + |
| 79 | + // Handle chevron hover - start delay timer |
| 80 | + const handleChevronMouseEnter = useCallback(() => { |
| 81 | + if (!isMenuOpen && !hoverTimeoutRef.current) { |
| 82 | + hoverTimeoutRef.current = setTimeout(() => { |
| 83 | + setIsMenuOpen(true); |
| 84 | + setLanguageSearch(""); |
| 85 | + hoverTimeoutRef.current = null; |
| 86 | + }, 175); // 150-200ms range midpoint |
| 87 | + } |
| 88 | + }, [isMenuOpen]); |
| 89 | + |
| 90 | + // Handle chevron hover leave - cancel timer before it fires |
| 91 | + const handleChevronMouseLeave = useCallback(() => { |
| 92 | + if (hoverTimeoutRef.current) { |
| 93 | + clearTimeout(hoverTimeoutRef.current); |
| 94 | + hoverTimeoutRef.current = null; |
| 95 | + } |
| 96 | + }, []); |
| 97 | + |
| 98 | + // Handle container mouse leave - start close timer |
| 99 | + const handleContainerMouseLeave = useCallback((e: React.MouseEvent) => { |
| 100 | + if (!isMenuOpen) return; |
| 101 | + |
| 102 | + // Cancel any existing close timeout |
| 103 | + if (closeTimeoutRef.current) { |
| 104 | + clearTimeout(closeTimeoutRef.current); |
| 105 | + } |
| 106 | + |
| 107 | + // Start a new close timeout |
| 108 | + closeTimeoutRef.current = setTimeout(() => { |
| 109 | + // Check if mouse is not over menu before closing |
| 110 | + if (menuRef.current && !menuRef.current.contains(document.elementFromPoint(e.clientX, e.clientY))) { |
| 111 | + setIsMenuOpen(false); |
| 112 | + setLanguageSearch(""); |
| 113 | + } |
| 114 | + closeTimeoutRef.current = null; |
| 115 | + }, 100); |
| 116 | + }, [isMenuOpen]); |
| 117 | + |
| 118 | + // Handle menu mouse enter - cancel close timer |
| 119 | + const handleMenuMouseEnter = useCallback(() => { |
| 120 | + if (closeTimeoutRef.current) { |
| 121 | + clearTimeout(closeTimeoutRef.current); |
| 122 | + closeTimeoutRef.current = null; |
| 123 | + } |
| 124 | + }, []); |
| 125 | + |
| 126 | + // Handle menu mouse leave - close menu after delay |
| 127 | + const handleMenuMouseLeave = useCallback(() => { |
| 128 | + if (closeTimeoutRef.current) { |
| 129 | + clearTimeout(closeTimeoutRef.current); |
| 130 | + } |
| 131 | + |
| 132 | + closeTimeoutRef.current = setTimeout(() => { |
| 133 | + setIsMenuOpen(false); |
| 134 | + setLanguageSearch(""); |
| 135 | + closeTimeoutRef.current = null; |
| 136 | + }, 100); |
| 137 | + }, []); |
| 138 | + |
| 139 | + // Handle language selection |
| 140 | + const handleLanguageSelect = useCallback((langCode: string) => { |
| 141 | + // Handle auth check |
| 142 | + if (!isAuthenticated && langCode !== 'en') { |
| 143 | + onRequestSignIn?.(); |
| 144 | + return; |
| 145 | + } |
| 146 | + |
| 147 | + // Clear any pending close timeout |
| 148 | + if (closeTimeoutRef.current) { |
| 149 | + clearTimeout(closeTimeoutRef.current); |
| 150 | + closeTimeoutRef.current = null; |
| 151 | + } |
| 152 | + |
| 153 | + // Toggle selection if clicking current language |
| 154 | + const newLanguage = langCode === currentLanguageCode && selectedLanguage !== null |
| 155 | + ? null |
| 156 | + : langCode; |
| 157 | + |
| 158 | + onLanguageChange?.(newLanguage); |
| 159 | + |
| 160 | + // Only switch to Transcript tab if NOT already on it |
| 161 | + if (activeTab !== 'transcript') { |
| 162 | + onTabSwitch('transcript'); |
| 163 | + } |
| 164 | + |
| 165 | + setIsMenuOpen(false); |
| 166 | + setLanguageSearch(""); |
| 167 | + }, [isAuthenticated, currentLanguageCode, selectedLanguage, activeTab, onLanguageChange, onTabSwitch, onRequestSignIn]); |
| 168 | + |
| 169 | + // Handle outside click - close menu without tab switch |
| 170 | + useEffect(() => { |
| 171 | + if (!isMenuOpen) return; |
| 172 | + |
| 173 | + const handleOutsideClick = (e: MouseEvent) => { |
| 174 | + const target = e.target as Node; |
| 175 | + // Check if click is outside both container and menu |
| 176 | + if (!containerRef.current?.contains(target) && !menuRef.current?.contains(target)) { |
| 177 | + setIsMenuOpen(false); |
| 178 | + setLanguageSearch(""); |
| 179 | + // NOTE: Explicitly NOT calling onTabSwitch here |
| 180 | + } |
| 181 | + }; |
| 182 | + |
| 183 | + // Use mousedown for faster response, but add a small delay to ensure |
| 184 | + // the language selection handler fires first |
| 185 | + const timeoutId = setTimeout(() => { |
| 186 | + document.addEventListener("mousedown", handleOutsideClick); |
| 187 | + }, 0); |
| 188 | + |
| 189 | + return () => { |
| 190 | + clearTimeout(timeoutId); |
| 191 | + document.removeEventListener("mousedown", handleOutsideClick); |
| 192 | + }; |
| 193 | + }, [isMenuOpen]); |
| 194 | + |
| 195 | + return ( |
| 196 | + <> |
| 197 | + <div |
| 198 | + ref={containerRef} |
| 199 | + className={cn( |
| 200 | + "flex items-center gap-0 rounded-2xl w-full", |
| 201 | + activeTab === "transcript" |
| 202 | + ? "bg-neutral-100" |
| 203 | + : "hover:bg-white/50" |
| 204 | + )} |
| 205 | + onMouseLeave={handleContainerMouseLeave} |
| 206 | + > |
| 207 | + <Button |
| 208 | + variant="ghost" |
| 209 | + size="sm" |
| 210 | + onClick={() => onTabSwitch("transcript")} |
| 211 | + className={cn( |
| 212 | + "flex-1 justify-center gap-2 rounded-l-2xl rounded-r-none border-0", |
| 213 | + activeTab === "transcript" |
| 214 | + ? "text-foreground hover:bg-neutral-100" |
| 215 | + : "text-muted-foreground hover:text-foreground hover:bg-transparent" |
| 216 | + )} |
| 217 | + > |
| 218 | + <Languages className="h-4 w-4" /> |
| 219 | + Transcript |
| 220 | + </Button> |
| 221 | + <Button |
| 222 | + ref={chevronRef} |
| 223 | + variant="ghost" |
| 224 | + size="sm" |
| 225 | + onMouseEnter={handleChevronMouseEnter} |
| 226 | + onMouseLeave={handleChevronMouseLeave} |
| 227 | + onClick={() => setIsMenuOpen(!isMenuOpen)} |
| 228 | + className={cn( |
| 229 | + "rounded-r-2xl rounded-l-none border-0 !pl-0", |
| 230 | + activeTab === "transcript" |
| 231 | + ? "text-foreground hover:bg-neutral-100" |
| 232 | + : "text-muted-foreground hover:text-foreground hover:bg-transparent" |
| 233 | + )} |
| 234 | + > |
| 235 | + <ChevronDown |
| 236 | + className="h-3 w-3 opacity-50" |
| 237 | + style={{ |
| 238 | + transform: isMenuOpen ? "rotate(0deg)" : "rotate(180deg)", |
| 239 | + transition: "transform 200ms cubic-bezier(0.4, 0, 0.2, 1)", |
| 240 | + }} |
| 241 | + /> |
| 242 | + </Button> |
| 243 | + </div> |
| 244 | + |
| 245 | + {isMounted && isMenuOpen && ( |
| 246 | + <LanguageSelectorMenu |
| 247 | + chevronRef={chevronRef} |
| 248 | + menuRef={menuRef} |
| 249 | + filteredLanguages={filteredLanguages} |
| 250 | + currentLanguageCode={currentLanguageCode} |
| 251 | + selectedLanguage={selectedLanguage} |
| 252 | + isAuthenticated={isAuthenticated} |
| 253 | + languageSearch={languageSearch} |
| 254 | + onLanguageSearchChange={setLanguageSearch} |
| 255 | + onLanguageSelect={handleLanguageSelect} |
| 256 | + onRequestSignIn={onRequestSignIn} |
| 257 | + onMenuMouseEnter={handleMenuMouseEnter} |
| 258 | + onMenuMouseLeave={handleMenuMouseLeave} |
| 259 | + /> |
| 260 | + )} |
| 261 | + </> |
| 262 | + ); |
| 263 | +} |
| 264 | + |
| 265 | +function LanguageSelectorMenu({ |
| 266 | + chevronRef, |
| 267 | + menuRef, |
| 268 | + filteredLanguages, |
| 269 | + currentLanguageCode, |
| 270 | + selectedLanguage, |
| 271 | + isAuthenticated, |
| 272 | + languageSearch, |
| 273 | + onLanguageSearchChange, |
| 274 | + onLanguageSelect, |
| 275 | + onRequestSignIn, |
| 276 | + onMenuMouseEnter, |
| 277 | + onMenuMouseLeave, |
| 278 | +}: LanguageSelectorMenuProps) { |
| 279 | + const [position, setPosition] = useState({ top: 0, left: 0 }); |
| 280 | + |
| 281 | + // Calculate and update menu position |
| 282 | + useEffect(() => { |
| 283 | + if (!chevronRef?.current) return; |
| 284 | + |
| 285 | + const updatePosition = () => { |
| 286 | + const rect = chevronRef.current!.getBoundingClientRect(); |
| 287 | + setPosition({ |
| 288 | + top: rect.bottom + 4, |
| 289 | + left: rect.left - 200, // Align with existing alignOffset |
| 290 | + }); |
| 291 | + }; |
| 292 | + |
| 293 | + updatePosition(); |
| 294 | + window.addEventListener('resize', updatePosition); |
| 295 | + window.addEventListener('scroll', updatePosition, true); |
| 296 | + |
| 297 | + return () => { |
| 298 | + window.removeEventListener('resize', updatePosition); |
| 299 | + window.removeEventListener('scroll', updatePosition, true); |
| 300 | + }; |
| 301 | + }, [chevronRef]); |
| 302 | + |
| 303 | + return createPortal( |
| 304 | + <div |
| 305 | + ref={menuRef} |
| 306 | + className="fixed z-50 w-[260px] rounded-2xl border bg-popover p-0 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95" |
| 307 | + style={{ |
| 308 | + top: `${position.top}px`, |
| 309 | + left: `${position.left}px`, |
| 310 | + }} |
| 311 | + onMouseEnter={onMenuMouseEnter} |
| 312 | + onMouseLeave={onMenuMouseLeave} |
| 313 | + > |
| 314 | + {!isAuthenticated && ( |
| 315 | + <div className="px-3 py-2 border-b"> |
| 316 | + <div className="text-xs font-medium">Sign in to translate</div> |
| 317 | + <div className="mt-1 text-[11px] text-muted-foreground"> |
| 318 | + Translate transcript and topics into 4 languages. |
| 319 | + </div> |
| 320 | + <Button |
| 321 | + size="sm" |
| 322 | + className="mt-2 h-7 text-xs w-full" |
| 323 | + onClick={(e) => { |
| 324 | + e.preventDefault(); |
| 325 | + onRequestSignIn?.(); |
| 326 | + }} |
| 327 | + > |
| 328 | + Sign in |
| 329 | + </Button> |
| 330 | + </div> |
| 331 | + )} |
| 332 | + <div className="px-2 py-1.5"> |
| 333 | + <div className="relative"> |
| 334 | + <Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" /> |
| 335 | + <Input |
| 336 | + placeholder="Search" |
| 337 | + value={languageSearch} |
| 338 | + onChange={(e) => onLanguageSearchChange(e.target.value)} |
| 339 | + className="h-7 pl-7 text-xs" |
| 340 | + /> |
| 341 | + </div> |
| 342 | + </div> |
| 343 | + <div className="max-h-[300px] overflow-y-auto"> |
| 344 | + {filteredLanguages.map((lang) => { |
| 345 | + const isOriginalLanguage = lang.code === 'en'; |
| 346 | + const isTargetLanguage = lang.code === currentLanguageCode && selectedLanguage !== null; |
| 347 | + |
| 348 | + return ( |
| 349 | + <div |
| 350 | + key={lang.code} |
| 351 | + className={cn( |
| 352 | + "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", |
| 353 | + isOriginalLanguage && "cursor-default", |
| 354 | + !isAuthenticated && !isOriginalLanguage && "opacity-50" |
| 355 | + )} |
| 356 | + onClick={(e) => { |
| 357 | + if (isOriginalLanguage || (!isAuthenticated && !isOriginalLanguage)) { |
| 358 | + if (!isAuthenticated && !isOriginalLanguage) { |
| 359 | + e.preventDefault(); |
| 360 | + onRequestSignIn?.(); |
| 361 | + } |
| 362 | + return; |
| 363 | + } |
| 364 | + onLanguageSelect(lang.code); |
| 365 | + }} |
| 366 | + > |
| 367 | + <div className="flex items-center justify-between w-full"> |
| 368 | + <div> |
| 369 | + <div className="font-medium">{lang.nativeName}</div> |
| 370 | + <div className="text-[10px] text-muted-foreground">{lang.name}</div> |
| 371 | + </div> |
| 372 | + {isOriginalLanguage ? ( |
| 373 | + <CheckCircle2 className="w-4 h-4 text-muted-foreground/50" /> |
| 374 | + ) : isTargetLanguage ? ( |
| 375 | + <CheckCircle2 className="w-4 h-4 text-foreground fill-background" /> |
| 376 | + ) : ( |
| 377 | + <Circle className="w-4 h-4 text-muted-foreground/30" /> |
| 378 | + )} |
| 379 | + </div> |
| 380 | + </div> |
| 381 | + ); |
| 382 | + })} |
| 383 | + </div> |
| 384 | + </div>, |
| 385 | + document.body |
| 386 | + ); |
| 387 | +} |
0 commit comments