| "use client"; |
|
|
| import { useState, useEffect, useRef } from "react"; |
| import { createPortal } from "react-dom"; |
| import { LOCALES, LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; |
| import { reloadTranslations } from "@/i18n/runtime"; |
|
|
| function getLocaleFromCookie() { |
| if (typeof document === "undefined") return "en"; |
| const cookie = document.cookie |
| .split(";") |
| .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); |
| const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; |
| return normalizeLocale(value); |
| } |
|
|
| |
| const getLocaleInfo = (locale) => { |
| const locales = { |
| "en": { name: "English", flag: "🇺🇸" }, |
| "vi": { name: "Tiếng Việt", flag: "🇻🇳" }, |
| "zh-CN": { name: "简体中文", flag: "🇨🇳" }, |
| "zh-TW": { name: "繁體中文", flag: "🇹🇼" }, |
| "ja": { name: "日本語", flag: "🇯🇵" }, |
| "pt-BR": { name: "Português (Brasil)", flag: "🇧🇷" }, |
| "pt-PT": { name: "Português (Portugal)", flag: "🇵🇹" }, |
| "ko": { name: "한국어", flag: "🇰🇷" }, |
| "es": { name: "Español", flag: "🇪🇸" }, |
| "de": { name: "Deutsch", flag: "🇩🇪" }, |
| "fr": { name: "Français", flag: "🇫🇷" }, |
| "he": { name: "עברית", flag: "🇮🇱" }, |
| "ar": { name: "العربية", flag: "🇸🇦" }, |
| "ru": { name: "Русский", flag: "🇷🇺" }, |
| "pl": { name: "Polski", flag: "🇵🇱" }, |
| "cs": { name: "Čeština", flag: "🇨🇿" }, |
| "nl": { name: "Nederlands", flag: "🇳🇱" }, |
| "tr": { name: "Türkçe", flag: "🇹🇷" }, |
| "uk": { name: "Українська", flag: "🇺🇦" }, |
| "tl": { name: "Tagalog", flag: "🇵🇭" }, |
| "id": { name: "Indonesia", flag: "🇮🇩" }, |
| "th": { name: "ไทย", flag: "🇹🇭" }, |
| "hi": { name: "हिन्दी", flag: "🇮🇳" }, |
| "bn": { name: "বাংলা", flag: "🇧🇩" }, |
| "ur": { name: "اردو", flag: "🇵🇰" }, |
| "ro": { name: "Română", flag: "🇷🇴" }, |
| "sv": { name: "Svenska", flag: "🇸🇪" }, |
| "it": { name: "Italiano", flag: "🇮🇹" }, |
| "el": { name: "Ελληνικά", flag: "🇬🇷" }, |
| "hu": { name: "Magyar", flag: "🇭🇺" }, |
| "fi": { name: "Suomi", flag: "🇫🇮" }, |
| "da": { name: "Dansk", flag: "🇩🇰" }, |
| "no": { name: "Norsk", flag: "🇳🇴" } |
| }; |
| return locales[locale] || { name: locale, flag: "🌐" }; |
| }; |
|
|
| export default function LanguageSwitcher({ className = "", isOpen: controlledOpen, onClose, hideTrigger = false }) { |
| const [locale, setLocale] = useState("en"); |
| const [isPending, setIsPending] = useState(false); |
| const [internalOpen, setInternalOpen] = useState(false); |
| const modalRef = useRef(null); |
|
|
| const isControlled = typeof controlledOpen === "boolean"; |
| const isOpen = isControlled ? controlledOpen : internalOpen; |
| const setIsOpen = (value) => { |
| if (isControlled) { |
| if (!value && onClose) onClose(locale); |
| } else { |
| setInternalOpen(value); |
| } |
| }; |
|
|
| useEffect(() => { |
| setLocale(getLocaleFromCookie()); |
| }, []); |
|
|
| |
| useEffect(() => { |
| function handleClickOutside(event) { |
| if (modalRef.current && !modalRef.current.contains(event.target)) { |
| setIsOpen(false); |
| } |
| } |
| if (isOpen) { |
| document.addEventListener("mousedown", handleClickOutside); |
| return () => document.removeEventListener("mousedown", handleClickOutside); |
| } |
| }, [isOpen]); |
|
|
| const handleSetLocale = async (nextLocale) => { |
| if (nextLocale === locale || isPending) return; |
|
|
| setIsPending(true); |
| setIsOpen(false); |
| try { |
| await fetch("/api/locale", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ locale: nextLocale }), |
| }); |
| |
| |
| await reloadTranslations(); |
| setLocale(nextLocale); |
| } catch (err) { |
| console.error("Failed to set locale:", err); |
| } finally { |
| setIsPending(false); |
| } |
| }; |
|
|
| return ( |
| <div className={className}> |
| {/* Trigger button */} |
| {!hideTrigger && ( |
| <button |
| onClick={() => setIsOpen(!isOpen)} |
| disabled={isPending} |
| className="flex items-center gap-2 px-3 py-2 rounded-lg text-text-muted hover:text-text-main hover:bg-surface/60 transition-colors" |
| title="Language" |
| data-i18n-skip="true" |
| > |
| <span className="material-symbols-outlined text-[20px]">language</span> |
| <span className="text-sm font-medium">{getLocaleInfo(locale).name}</span> |
| <span className="text-lg">{getLocaleInfo(locale).flag}</span> |
| </button> |
| )} |
| |
| {/* Portal modal - renders at document.body to avoid parent layout constraints */} |
| {isOpen && createPortal( |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4" data-i18n-skip="true"> |
| {/* Overlay */} |
| <div |
| className="absolute inset-0 bg-black/30 backdrop-blur-sm" |
| onClick={() => setIsOpen(false)} |
| /> |
| |
| {/* Modal content */} |
| <div |
| ref={modalRef} |
| className="relative w-full bg-surface border border-black/10 dark:border-white/10 rounded-xl shadow-2xl animate-in fade-in zoom-in-95 duration-200 max-w-2xl flex flex-col max-h-[80vh]" |
| > |
| {/* Modal header */} |
| <div className="flex items-center justify-between p-3 border-b border-black/5 dark:border-white/5"> |
| <h2 className="text-lg font-semibold text-text-main">Select Language</h2> |
| <button |
| onClick={() => setIsOpen(false)} |
| className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors" |
| aria-label="Close" |
| > |
| <span className="material-symbols-outlined text-[20px]">close</span> |
| </button> |
| </div> |
| |
| {/* Modal body - fixed grid columns, equal sizing */} |
| <div className="p-6 overflow-y-auto flex-1"> |
| <div className="grid grid-cols-[repeat(auto-fill,minmax(100px,1fr))] gap-2"> |
| {LOCALES.map((item) => { |
| const active = locale === item; |
| const info = getLocaleInfo(item); |
| return ( |
| <button |
| key={item} |
| onClick={() => handleSetLocale(item)} |
| disabled={isPending} |
| className={`flex flex-col items-center justify-start gap-1 px-2 py-3 rounded-lg text-xs font-medium transition-colors w-full ${ |
| active |
| ? "bg-primary/15 text-primary ring-2 ring-primary" |
| : "text-text-main hover:bg-black/5 dark:hover:bg-white/5" |
| } ${isPending ? "opacity-70 cursor-wait" : ""}`} |
| title={info.name} |
| > |
| <span className="text-2xl">{info.flag}</span> |
| {/* Fixed 2-line height so all cards are uniform */} |
| <span className="text-center leading-tight line-clamp-2 h-8 flex items-center">{info.name}</span> |
| {active && ( |
| <span className="material-symbols-outlined text-sm">check</span> |
| )} |
| </button> |
| ); |
| })} |
| </div> |
| </div> |
| </div> |
| </div>, |
| document.body |
| )} |
| </div> |
| ); |
| } |
|
|