| @@ -1,6 +1,7 @@ | |||||
| import MUIAppBar from "@mui/material/AppBar"; | import MUIAppBar from "@mui/material/AppBar"; | ||||
| import Toolbar from "@mui/material/Toolbar"; | import Toolbar from "@mui/material/Toolbar"; | ||||
| import React from "react"; | import React from "react"; | ||||
| import LanguageSwitcher from "./LanguageSwitcher"; | |||||
| import Profile from "./Profile"; | import Profile from "./Profile"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import NavigationToggle from "./NavigationToggle"; | import NavigationToggle from "./NavigationToggle"; | ||||
| @@ -35,6 +36,7 @@ const AppBar: React.FC<AppBarProps> = ({ avatarImageSrc, profileName }) => { | |||||
| gap: 1, | gap: 1, | ||||
| }} | }} | ||||
| > | > | ||||
| <LanguageSwitcher /> | |||||
| <Profile | <Profile | ||||
| avatarImageSrc={avatarImageSrc} | avatarImageSrc={avatarImageSrc} | ||||
| profileName={profileName} | profileName={profileName} | ||||
| @@ -0,0 +1,60 @@ | |||||
| "use client"; | |||||
| import React, { useRef } from "react"; | |||||
| import ToggleButton from "@mui/material/ToggleButton"; | |||||
| import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; | |||||
| import { useRouter } from "next/navigation"; | |||||
| import { useSession } from "next-auth/react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| type AppLanguage, | |||||
| isAppLanguage, | |||||
| setLanguageCookie, | |||||
| } from "@/i18n/locale"; | |||||
| const LanguageSwitcher: React.FC = () => { | |||||
| const { i18n, t } = useTranslation("common"); | |||||
| const { update } = useSession(); | |||||
| const router = useRouter(); | |||||
| const inFlightRef = useRef(false); | |||||
| const current: AppLanguage = isAppLanguage(i18n.language) ? i18n.language : "zh"; | |||||
| const onChange = async (_: React.MouseEvent<HTMLElement>, next: AppLanguage | null) => { | |||||
| if (!next || next === current) return; | |||||
| if (inFlightRef.current) return; | |||||
| inFlightRef.current = true; | |||||
| try { | |||||
| setLanguageCookie(next); | |||||
| await update({ locale: next }); | |||||
| router.refresh(); | |||||
| } finally { | |||||
| inFlightRef.current = false; | |||||
| } | |||||
| }; | |||||
| return ( | |||||
| <ToggleButtonGroup | |||||
| exclusive | |||||
| size="small" | |||||
| value={current} | |||||
| onChange={onChange} | |||||
| aria-label={t("Language")} | |||||
| sx={{ | |||||
| mr: 0.5, | |||||
| "& .MuiToggleButton-root": { | |||||
| px: 1, | |||||
| py: 0.25, | |||||
| fontSize: "0.75rem", | |||||
| lineHeight: 1.4, | |||||
| textTransform: "none", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <ToggleButton value="zh">中</ToggleButton> | |||||
| <ToggleButton value="en">EN</ToggleButton> | |||||
| </ToggleButtonGroup> | |||||
| ); | |||||
| }; | |||||
| export default LanguageSwitcher; | |||||
| @@ -12,6 +12,7 @@ declare module "next-auth" { | |||||
| id?: string; | id?: string; | ||||
| /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ | /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ | ||||
| exp?: number; | exp?: number; | ||||
| locale?: string; | |||||
| } | } | ||||
| interface User { | interface User { | ||||
| @@ -19,6 +20,7 @@ declare module "next-auth" { | |||||
| accessToken: string | null; | accessToken: string | null; | ||||
| refreshToken?: string; | refreshToken?: string; | ||||
| abilities: string[]; | abilities: string[]; | ||||
| locale?: string; | |||||
| } | } | ||||
| } | } | ||||
| @@ -28,6 +30,7 @@ declare module "next-auth/jwt" { | |||||
| accessToken: string | null; | accessToken: string | null; | ||||
| refreshToken?: string; | refreshToken?: string; | ||||
| abilities: string[]; | abilities: string[]; | ||||
| locale?: string; | |||||
| } | } | ||||
| } | } | ||||
| @@ -70,13 +73,24 @@ export const authOptions: AuthOptions = { | |||||
| }, | }, | ||||
| callbacks: { | callbacks: { | ||||
| // Persist custom fields into the JWT token | // Persist custom fields into the JWT token | ||||
| async jwt({ token, user }) { | |||||
| async jwt({ token, user, trigger, session }) { | |||||
| // First sign-in: `user` is available | // First sign-in: `user` is available | ||||
| if (user) { | if (user) { | ||||
| token.id = user.id ?? token.sub; // fallback to sub if no id | token.id = user.id ?? token.sub; // fallback to sub if no id | ||||
| token.accessToken = user.accessToken; | token.accessToken = user.accessToken; | ||||
| token.refreshToken = user.refreshToken; | token.refreshToken = user.refreshToken; | ||||
| token.abilities = user.abilities ?? []; | token.abilities = user.abilities ?? []; | ||||
| const loginLocale = (user as { locale?: string }).locale; | |||||
| if (loginLocale) { | |||||
| token.locale = loginLocale; | |||||
| } | |||||
| } | |||||
| if (trigger === "update" && session && typeof session === "object" && "locale" in session) { | |||||
| const next = (session as { locale?: string }).locale; | |||||
| if (next === "zh" || next === "en") { | |||||
| token.locale = next; | |||||
| } | |||||
| } | } | ||||
| // On subsequent calls (token refresh, session access), user is not present | // On subsequent calls (token refresh, session access), user is not present | ||||
| @@ -91,6 +105,7 @@ export const authOptions: AuthOptions = { | |||||
| session.refreshToken = token.refreshToken as string | undefined; | session.refreshToken = token.refreshToken as string | undefined; | ||||
| session.abilities = token.abilities as string[]; | session.abilities = token.abilities as string[]; | ||||
| session.exp = token.exp as number | undefined; | session.exp = token.exp as number | undefined; | ||||
| session.locale = token.locale as string | undefined; | |||||
| // Also add abilities to session.user for easier client-side access | // Also add abilities to session.user for easier client-side access | ||||
| if (session.user) { | if (session.user) { | ||||
| @@ -107,5 +122,6 @@ export type SessionWithTokens = Session & { | |||||
| abilities: string[]; | abilities: string[]; | ||||
| /** Backend / JWT subject — often numeric string or number */ | /** Backend / JWT subject — often numeric string or number */ | ||||
| id?: string | number; | id?: string | number; | ||||
| locale?: string; | |||||
| }; | }; | ||||
| export default authOptions; | export default authOptions; | ||||
| @@ -87,6 +87,7 @@ | |||||
| "Select Date": "選擇日期", | "Select Date": "選擇日期", | ||||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | "Session expired or unauthorized.": "工作階段已過期或未經授權。", | ||||
| "Sign out": "Sign out", | "Sign out": "Sign out", | ||||
| "Language": "Language", | |||||
| "Status": "狀態", | "Status": "狀態", | ||||
| "Stock Qty": "庫存數量", | "Stock Qty": "庫存數量", | ||||
| "Supporting Document": "證明文件", | "Supporting Document": "證明文件", | ||||
| @@ -2,12 +2,14 @@ import { cookies, headers } from "next/headers"; | |||||
| import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next"; | import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next"; | ||||
| import resourcesToBackend from "i18next-resources-to-backend"; | import resourcesToBackend from "i18next-resources-to-backend"; | ||||
| import { getServerSession } from "next-auth"; | import { getServerSession } from "next-auth"; | ||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { authOptions, SessionWithTokens } from "@/config/authConfig"; | |||||
| import I18nClientProvider from "./I18nClientProvider"; | import I18nClientProvider from "./I18nClientProvider"; | ||||
| import universalLanguageDetect from "@unly/universal-language-detector"; | import universalLanguageDetect from "@unly/universal-language-detector"; | ||||
| const FALLBACK_LANG = "zh"; | |||||
| const SUPPORTED_LANGUAGES = ["zh"]; | |||||
| import { | |||||
| FALLBACK_LANG, | |||||
| SUPPORTED_LANGUAGES, | |||||
| normalizeAppLanguage, | |||||
| } from "./locale"; | |||||
| export const detectLanguage = async (): Promise<string> => { | export const detectLanguage = async (): Promise<string> => { | ||||
| // Logic to get language preference from cookies/headers/session | // Logic to get language preference from cookies/headers/session | ||||
| @@ -21,11 +23,13 @@ export const detectLanguage = async (): Promise<string> => { | |||||
| const headersList = headers(); | const headersList = headers(); | ||||
| //console.time("[i18n] detectLanguage total"); | //console.time("[i18n] detectLanguage total"); | ||||
| //console.time("[i18n] getServerSession"); | //console.time("[i18n] getServerSession"); | ||||
| const session = await getServerSession(authOptions); | |||||
| //console.timeEnd("[i18n] getServerSession"); | |||||
| //console.time("[i18n] universalLanguageDetect"); | |||||
| const session = (await getServerSession(authOptions)) as SessionWithTokens | null; | |||||
| const fromSession = normalizeAppLanguage(session?.locale); | |||||
| if (fromSession) { | |||||
| return fromSession; | |||||
| } | |||||
| const lang = universalLanguageDetect({ | const lang = universalLanguageDetect({ | ||||
| supportedLanguages: SUPPORTED_LANGUAGES, | |||||
| supportedLanguages: [...SUPPORTED_LANGUAGES], | |||||
| fallbackLanguage: FALLBACK_LANG, | fallbackLanguage: FALLBACK_LANG, | ||||
| acceptLanguageHeader: headersList.get("accept-language") || undefined, | acceptLanguageHeader: headersList.get("accept-language") || undefined, | ||||
| serverCookies: cookiesObj, | serverCookies: cookiesObj, | ||||
| @@ -0,0 +1,23 @@ | |||||
| export const FALLBACK_LANG = "zh"; | |||||
| export const SUPPORTED_LANGUAGES = ["zh", "en"] as const; | |||||
| export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]; | |||||
| /** Cookie name read by `@unly/universal-language-detector`. */ | |||||
| export const I18N_COOKIE_NAME = "i18next"; | |||||
| export function isAppLanguage(value: unknown): value is AppLanguage { | |||||
| return value === "zh" || value === "en"; | |||||
| } | |||||
| export function normalizeAppLanguage(value: unknown): AppLanguage | null { | |||||
| if (typeof value !== "string") return null; | |||||
| const lower = value.trim().toLowerCase(); | |||||
| if (lower === "zh" || lower.startsWith("zh-")) return "zh"; | |||||
| if (lower === "en" || lower.startsWith("en-")) return "en"; | |||||
| return null; | |||||
| } | |||||
| export function setLanguageCookie(lang: AppLanguage) { | |||||
| if (typeof document === "undefined") return; | |||||
| document.cookie = `${I18N_COOKIE_NAME}=${lang}; Path=/; SameSite=Lax; Max-Age=31536000`; | |||||
| } | |||||
| @@ -90,6 +90,7 @@ | |||||
| "Select Date": "選擇日期", | "Select Date": "選擇日期", | ||||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | "Session expired or unauthorized.": "工作階段已過期或未經授權。", | ||||
| "Sign out": "登出", | "Sign out": "登出", | ||||
| "Language": "語言", | |||||
| "Status": "狀態", | "Status": "狀態", | ||||
| "Stock Qty": "庫存數量", | "Stock Qty": "庫存數量", | ||||
| "Supporting Document": "證明文件", | "Supporting Document": "證明文件", | ||||