169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { Menu as MenuIcon, X as CloseIcon } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
|
|
import {
|
|
DEFAULT_LOCALE,
|
|
I18nKey,
|
|
LOCALE_LABELS,
|
|
Locale,
|
|
SUPPORTED_LOCALES,
|
|
translateText,
|
|
} from "@/shared/config/i18n";
|
|
import {
|
|
resolveInitialLocale,
|
|
saveManualLocale,
|
|
} from "@/features/i18n/model/resolveInitialLocale";
|
|
import { I18nProvider } from "@/features/i18n/model/useI18n";
|
|
|
|
export function I18nLayoutShell({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const pathname = usePathname();
|
|
const isLobby = pathname === "/";
|
|
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const initialLocale = resolveInitialLocale();
|
|
setLocale(initialLocale);
|
|
document.documentElement.lang = initialLocale;
|
|
}, []);
|
|
|
|
const handleSetLocale = (nextLocale: Locale) => {
|
|
if (!SUPPORTED_LOCALES.includes(nextLocale)) return;
|
|
setLocale(nextLocale);
|
|
saveManualLocale(nextLocale);
|
|
document.documentElement.lang = nextLocale;
|
|
};
|
|
|
|
const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
|
const nextLocale = event.target.value as Locale;
|
|
handleSetLocale(nextLocale);
|
|
};
|
|
|
|
const t = (key: I18nKey) => translateText(locale, key);
|
|
|
|
useEffect(() => {
|
|
setIsMenuOpen(false);
|
|
}, [pathname]);
|
|
|
|
useEffect(() => {
|
|
if (!isMenuOpen) return;
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (!menuRef.current) return;
|
|
const target = event.target as Node | null;
|
|
if (target && !menuRef.current.contains(target)) {
|
|
setIsMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key !== "Escape") return;
|
|
setIsMenuOpen(false);
|
|
menuButtonRef.current?.focus();
|
|
};
|
|
|
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
document.addEventListener("keydown", handleEscape);
|
|
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
document.removeEventListener("keydown", handleEscape);
|
|
};
|
|
}, [isMenuOpen]);
|
|
|
|
return (
|
|
<I18nProvider locale={locale} setLocale={handleSetLocale}>
|
|
<div
|
|
className={`relative z-10 mx-auto flex min-h-screen flex-col ${
|
|
isLobby ? "w-full max-w-none" : "max-w-6xl"
|
|
}`}
|
|
>
|
|
<header
|
|
className={`flex items-center justify-between px-6 py-4 ${
|
|
isLobby ? "lg:hidden" : ""
|
|
}`}
|
|
>
|
|
<Link
|
|
href="/"
|
|
className="z-50 text-lg font-bold tracking-wider text-indigo-400 transition-colors hover:text-indigo-300"
|
|
>
|
|
FOCUSTELLA
|
|
</Link>
|
|
<div
|
|
ref={menuRef}
|
|
className="relative z-50"
|
|
onBlur={(event) => {
|
|
if (!isMenuOpen) return;
|
|
const nextFocused = event.relatedTarget as Node | null;
|
|
if (nextFocused && event.currentTarget.contains(nextFocused)) {
|
|
return;
|
|
}
|
|
setIsMenuOpen(false);
|
|
}}
|
|
>
|
|
<button
|
|
ref={menuButtonRef}
|
|
type="button"
|
|
onClick={() => setIsMenuOpen((prev) => !prev)}
|
|
aria-expanded={isMenuOpen}
|
|
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
|
|
className="rounded-lg border border-slate-700 bg-slate-900/70 p-2 text-slate-200 transition-colors hover:border-slate-500 hover:bg-slate-800/80"
|
|
>
|
|
{isMenuOpen ? (
|
|
<CloseIcon className="h-5 w-5" aria-hidden />
|
|
) : (
|
|
<MenuIcon className="h-5 w-5" aria-hidden />
|
|
)}
|
|
</button>
|
|
{isMenuOpen && (
|
|
<div className="absolute right-0 mt-2 w-64 rounded-xl border border-slate-700 bg-slate-900/95 p-3 shadow-2xl backdrop-blur">
|
|
<nav className="flex flex-col gap-2 text-sm font-medium text-slate-300">
|
|
<Link
|
|
href="/log"
|
|
className="rounded-md px-3 py-2 transition-colors hover:bg-slate-800 hover:text-slate-100"
|
|
>
|
|
{t("layout.nav.log")}
|
|
</Link>
|
|
<Link
|
|
href="/settings"
|
|
className="rounded-md px-3 py-2 transition-colors hover:bg-slate-800 hover:text-slate-100"
|
|
>
|
|
{t("layout.nav.settings")}
|
|
</Link>
|
|
<div className="rounded-md px-3 py-2">
|
|
<label className="flex items-center justify-between gap-3 text-xs text-slate-300">
|
|
<span>{t("layout.nav.language")}</span>
|
|
<select
|
|
value={locale}
|
|
onChange={handleLocaleChange}
|
|
className="rounded border border-slate-700 bg-slate-900/70 px-2 py-1 text-xs text-slate-200 outline-none transition-colors focus:border-indigo-400"
|
|
>
|
|
{SUPPORTED_LOCALES.map((item) => (
|
|
<option key={item} value={item}>
|
|
{LOCALE_LABELS[item]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
<main className="relative flex w-full flex-1 flex-col">{children}</main>
|
|
</div>
|
|
</I18nProvider>
|
|
);
|
|
}
|