feat: 항해 종료 시 버튼을 꾹 눌러서 종료

This commit is contained in:
2026-02-14 21:28:17 +09:00
parent 166d04384f
commit 332c2c5996
9 changed files with 232 additions and 61 deletions

View File

@@ -1,7 +1,9 @@
"use client";
import { Menu as MenuIcon, X as CloseIcon } from "lucide-react";
import Link from "next/link";
import { ChangeEvent, useEffect, useState } from "react";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import {
DEFAULT_LOCALE,
@@ -22,7 +24,12 @@ export function I18nLayoutShell({
}: {
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();
@@ -44,41 +51,115 @@ export function I18nLayoutShell({
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 max-w-6xl flex-col">
<header className="flex items-center justify-between px-6 py-4">
<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>
<nav className="z-50 flex items-center gap-4 text-sm font-medium text-slate-400">
<Link href="/log" className="transition-colors hover:text-slate-200">
{t("layout.nav.log")}
</Link>
<Link
href="/settings"
className="transition-colors hover:text-slate-200"
<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"
>
{t("layout.nav.settings")}
</Link>
<label className="flex items-center gap-2 text-xs text-slate-400">
<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>
</nav>
{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>