diff --git a/src/components/i18n-hydration-provider.tsx b/src/components/i18n-hydration-provider.tsx new file mode 100644 index 0000000..e3399dd --- /dev/null +++ b/src/components/i18n-hydration-provider.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; + +import { syncPreferredLanguage } from "@/i18n"; + +const I18nHydrationContext = createContext(false); + +/** hydration 完成前保持 DEFAULT_LANGUAGE,与 SSR 一致;完成后再同步用户偏好语言。 */ +export function I18nHydrationProvider({ children }: { children: ReactNode }) { + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + syncPreferredLanguage(); + setHydrated(true); + }, []); + + return ( + {children} + ); +} + +export function useI18nHydrated(): boolean { + return useContext(I18nHydrationContext); +} \ No newline at end of file diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx index 6c70e87..cbbc13e 100644 --- a/src/components/language-switcher.tsx +++ b/src/components/language-switcher.tsx @@ -6,8 +6,15 @@ import { ChevronDown, Globe } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { useI18nHydrated } from "@/components/i18n-hydration-provider"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { normalizeLanguage, SUPPORTED_LANGUAGES, type AppLanguage } from "@/i18n"; +import zhCommon from "@/i18n/locales/zh/common.json"; +import { + DEFAULT_LANGUAGE, + normalizeLanguage, + SUPPORTED_LANGUAGES, + type AppLanguage, +} from "@/i18n"; import { playerViewportPopoverMaxClass } from "@/lib/player-viewport"; import { cn } from "@/lib/utils"; @@ -33,6 +40,7 @@ export function LanguageSwitcher({ const { i18n, t } = useTranslation("common"); const active = normalizeLanguage(i18n.language) as AppLanguage; const [isOpen, setIsOpen] = useState(false); + const mounted = useI18nHydrated(); const options = useMemo( () => @@ -49,9 +57,14 @@ export function LanguageSwitcher({ setIsOpen(false); } - const currentLabel = useFullLabel - ? (options.find((o) => o.code === active)?.label ?? active.toUpperCase()) - : (options.find((o) => o.code === active)?.short ?? active.toUpperCase()); + const displayLang = mounted ? active : DEFAULT_LANGUAGE; + const currentLabel = mounted + ? useFullLabel + ? (options.find((o) => o.code === displayLang)?.label ?? displayLang.toUpperCase()) + : (options.find((o) => o.code === displayLang)?.short ?? displayLang.toUpperCase()) + : useFullLabel + ? zhCommon.language[displayLang] + : zhCommon.languageShort[displayLang]; const currentFlag = options.find((o) => o.code === active)?.flag ?? ""; const variantStyles = { @@ -105,7 +118,9 @@ export function LanguageSwitcher({ > {showFlag && currentFlag ? {currentFlag} : null} - {showLabel ? {currentLabel} : null} + {showLabel ? ( + {currentLabel} + ) : null} { - syncPreferredLanguage(); - }, []); - return ( <> + {/* iframe 通信桥接 - 支持主站嵌入 */} @@ -32,6 +29,7 @@ export function Providers({ children }: ProvidersProps): ReactNode { + ); diff --git a/src/features/player/entry-hero-banner.tsx b/src/features/player/entry-hero-banner.tsx index 75a7c1b..ad0ddd8 100644 --- a/src/features/player/entry-hero-banner.tsx +++ b/src/features/player/entry-hero-banner.tsx @@ -24,22 +24,19 @@ export function EntryHeroBanner({ return (
-
- {alt} -
+ {alt} {children}
); diff --git a/src/features/player/player-login-desktop.tsx b/src/features/player/player-login-desktop.tsx index f551b27..c9e86bf 100644 --- a/src/features/player/player-login-desktop.tsx +++ b/src/features/player/player-login-desktop.tsx @@ -1,6 +1,7 @@ "use client"; import type { TFunction } from "i18next"; +import Image from "next/image"; import { LanguageSwitcher } from "@/components/language-switcher"; import { PlayerLoginForm } from "@/features/player/player-login-form"; @@ -20,7 +21,7 @@ type PlayerLoginDesktopProps = { onSubmit: (event: React.FormEvent) => void; }; -/** PC 端登录:左右分栏卡片,左侧插画占位。 */ +/** PC 端登录:左右分栏卡片,小屏桌面下收紧高度与留白。 */ export function PlayerLoginDesktop({ t, username, @@ -36,7 +37,10 @@ export function PlayerLoginDesktop({ onSubmit, }: PlayerLoginDesktopProps) { return ( -
+
-
- {/* 左侧插画:图片待替换 */} +
+ {/* 左侧插画:暂用移动端同款 image1,后续可替换 */}
+ > + +
-
+
-
-

- {t("login.title")} -

-

- {t("login.hint")} -

- +
); -} \ No newline at end of file +} diff --git a/src/features/player/player-login-form.tsx b/src/features/player/player-login-form.tsx index ee90b5f..b77b1d6 100644 --- a/src/features/player/player-login-form.tsx +++ b/src/features/player/player-login-form.tsx @@ -43,51 +43,47 @@ export function PlayerLoginForm({ const [showPassword, setShowPassword] = useState(false); const isDesktop = variant === "desktop"; - const inputClass = cn( - isDesktop && "h-10 rounded-xl border-gray-200 bg-white shadow-none", - ); + const inputClass = + "h-10 rounded-xl border-gray-200 bg-white text-base shadow-none"; const captchaButtonClass = cn( - "flex shrink-0 cursor-pointer items-center justify-center overflow-hidden border border-input bg-muted/40 transition-colors hover:bg-muted/60 disabled:pointer-events-none disabled:opacity-50", - isDesktop - ? "h-10 min-w-[8.5rem] rounded-xl border-gray-200 bg-[#f4fbf6]" - : "h-8 min-w-[7.5rem] rounded-lg px-2 text-sm", + "flex h-10 min-w-[8.5rem] shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-gray-200 bg-[#f4fbf6] transition-colors hover:bg-[#eaf7ee] disabled:pointer-events-none disabled:opacity-50", ); return (
- +
- {isDesktop ? ( - - ) : null} + onUsernameChange(e.target.value)} autoComplete="username" disabled={loading} - placeholder={isDesktop ? t("login.usernamePlaceholder") : undefined} - className={cn(inputClass, isDesktop && "pl-10")} + placeholder={t("login.usernamePlaceholder")} + className={cn(inputClass, "pl-10")} />
- +
- {isDesktop ? ( - - ) : null} + onPasswordChange(e.target.value)} autoComplete="current-password" disabled={loading} - placeholder={isDesktop ? t("login.passwordPlaceholder") : undefined} - className={cn(inputClass, isDesktop && "pr-10 pl-10")} + placeholder={t("login.passwordPlaceholder")} + className={cn(inputClass, "pr-10 pl-10")} /> - {isDesktop ? ( - - ) : null} +
- +
- {isDesktop ? ( - - ) : null} +
-
-
-

{t("login.title")}

-

{t("login.hint")}

- +
+
diff --git a/src/i18n/hydration-t.ts b/src/i18n/hydration-t.ts new file mode 100644 index 0000000..87c2a62 --- /dev/null +++ b/src/i18n/hydration-t.ts @@ -0,0 +1,46 @@ +"use client"; + +import type { TFunction } from "i18next"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { useI18nHydrated } from "@/components/i18n-hydration-provider"; +import { DEFAULT_LANGUAGE, type AppLanguage } from "@/i18n/language"; +import enEntry from "@/i18n/locales/en/entry.json"; +import neEntry from "@/i18n/locales/ne/entry.json"; +import zhEntry from "@/i18n/locales/zh/entry.json"; + +const STATIC_ENTRY: Record = { + zh: zhEntry, + en: enEntry, + ne: neEntry, +}; + +function getStaticValue(obj: Record, key: string): string { + const parts = key.split("."); + let cur: unknown = obj; + for (const part of parts) { + if (cur == null || typeof cur !== "object") return key; + cur = (cur as Record)[part]; + } + return typeof cur === "string" ? cur : key; +} + +function createStaticEntryT(): TFunction<"entry"> { + const fn = ((key: string) => + getStaticValue(STATIC_ENTRY[DEFAULT_LANGUAGE] as Record, key)) as TFunction< + "entry" + >; + return fn; +} + +/** entry 命名空间:hydration 前固定 DEFAULT_LANGUAGE 文案,避免 SSR/客户端首屏不一致。 */ +export function useHydrationSafeEntryT(): TFunction<"entry"> { + const hydrated = useI18nHydrated(); + const { t } = useTranslation("entry"); + + return useMemo( + () => (hydrated ? t : createStaticEntryT()), + [hydrated, t], + ); +} \ No newline at end of file