- 在 .env.example 中新增 i18next 相关配置项以支持多语言功能 - 在 next.config.ts 中添加安全头配置以支持 iframe 嵌入 - 更新 Providers 组件以引入 i18n 配置 - 在 PlayerAppShell 中集成 LanguageSwitcher 组件以实现语言切换功能 - 优化 HallWalletStrip 组件的网络状态管理逻辑 - 更新多个组件以支持国际化文本
170 lines
5.5 KiB
TypeScript
170 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronDown, Globe } from "lucide-react";
|
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { normalizeLanguage, SUPPORTED_LANGUAGES, type AppLanguage } from "@/i18n";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface LanguageSwitcherProps {
|
|
variant?: "default" | "header" | "minimal";
|
|
/** 下拉相对触发器水平对齐:`start`=左对齐(适合左上角触发器),`end`=右对齐(适合顶栏右侧) */
|
|
menuAlign?: "start" | "end";
|
|
className?: string;
|
|
showFlag?: boolean;
|
|
showLabel?: boolean;
|
|
}
|
|
|
|
export function LanguageSwitcher({
|
|
variant = "default",
|
|
menuAlign,
|
|
className,
|
|
showFlag = true,
|
|
showLabel = true,
|
|
}: LanguageSwitcherProps) {
|
|
const { i18n, t } = useTranslation("common");
|
|
const active = normalizeLanguage(i18n.language) as AppLanguage;
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const options = useMemo(
|
|
() =>
|
|
SUPPORTED_LANGUAGES.map((item) => ({
|
|
...item,
|
|
label: t(`language.${item.code}`),
|
|
short: t(`languageShort.${item.code}`),
|
|
})),
|
|
[t, i18n.language],
|
|
);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent): void {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
if (isOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
async function handleSelect(code: AppLanguage): Promise<void> {
|
|
await i18n.changeLanguage(code);
|
|
setIsOpen(false);
|
|
}
|
|
|
|
const currentLabel = options.find((o) => o.code === active)?.short ?? active.toUpperCase();
|
|
const currentFlag = options.find((o) => o.code === active)?.flag ?? "";
|
|
|
|
const variantStyles = {
|
|
default: {
|
|
button: "border border-white/20 bg-white/10 text-white hover:bg-white/20",
|
|
dropdown: "border border-gray-200 bg-white shadow-lg",
|
|
item: "text-gray-800 hover:bg-gray-100",
|
|
activeItem: "bg-red-50 text-red-600",
|
|
},
|
|
header: {
|
|
button: "text-white/80 hover:bg-white/10 hover:text-white",
|
|
dropdown: "border border-white/20 bg-white/95 shadow-xl backdrop-blur-sm",
|
|
item: "text-gray-800 hover:bg-white/10",
|
|
activeItem: "bg-red-500/10 text-red-600",
|
|
},
|
|
minimal: {
|
|
button: "text-current hover:bg-black/5",
|
|
dropdown: "border border-gray-200 bg-white shadow-lg",
|
|
item: "text-gray-800 hover:bg-gray-100",
|
|
activeItem: "bg-red-50 text-red-600",
|
|
},
|
|
} as const;
|
|
|
|
const styles = variantStyles[variant];
|
|
|
|
const align =
|
|
menuAlign ?? (variant === "header" || variant === "default" ? "start" : "end");
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn("relative inline-block", className)}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
|
|
styles.button,
|
|
)}
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="listbox"
|
|
aria-label={`Language (${currentLabel})`}
|
|
>
|
|
<Globe className="size-4" aria-hidden />
|
|
{showFlag && currentFlag ? <span className="text-base">{currentFlag}</span> : null}
|
|
{showLabel ? <span>{currentLabel}</span> : null}
|
|
<ChevronDown
|
|
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")}
|
|
aria-hidden
|
|
/>
|
|
</button>
|
|
|
|
{isOpen ? (
|
|
<div
|
|
className={cn(
|
|
"absolute z-[100] mt-1 min-w-[min(100vw-2rem,220px)] max-w-[min(100vw-2rem,280px)] rounded-lg py-1 text-gray-900 shadow-md",
|
|
align === "start" ? "left-0" : "right-0",
|
|
styles.dropdown,
|
|
)}
|
|
role="listbox"
|
|
>
|
|
<div className="max-h-[280px] overflow-y-auto">
|
|
{options.map((option) => (
|
|
<button
|
|
key={option.code}
|
|
type="button"
|
|
onClick={() => void handleSelect(option.code)}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
|
active === option.code ? styles.activeItem : styles.item,
|
|
)}
|
|
role="option"
|
|
aria-selected={active === option.code}
|
|
>
|
|
<span className="text-lg">{option.flag}</span>
|
|
<div className="flex flex-col leading-tight">
|
|
<span className="font-medium">{option.label}</span>
|
|
<span className="text-xs opacity-60">{option.short}</span>
|
|
</div>
|
|
{active === option.code ? (
|
|
<svg
|
|
className="ml-auto size-4 text-red-500"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<title>{option.label}</title>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function LanguageSwitcherMinimal({
|
|
className,
|
|
}: {
|
|
className?: string;
|
|
}): ReactNode {
|
|
return (
|
|
<LanguageSwitcher variant="minimal" className={className} showFlag={false} />
|
|
);
|
|
}
|