diff --git a/.cli/planner/runbook_planner.md b/.cli/planner/runbook_planner.md index 97eeae3..348607e 100644 --- a/.cli/planner/runbook_planner.md +++ b/.cli/planner/runbook_planner.md @@ -8,7 +8,7 @@ ## 실행 절차(필수) 1. 아래 문서를 참고(필요 시)한다: - - `@.cli/spec.md` + - `@.cli/product/spec.md` - `@.cli/docs/architecture.md` - `@.cli/docs/rules.md` diff --git a/.cli/spec.md b/.cli/product/spec.md similarity index 100% rename from .cli/spec.md rename to .cli/product/spec.md diff --git a/src/app/log/[id]/page.tsx b/src/app/log/[id]/page.tsx index 3fff774..22011bc 100644 --- a/src/app/log/[id]/page.tsx +++ b/src/app/log/[id]/page.tsx @@ -31,9 +31,14 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string return (
- - ← {t('log.detail.back')} - +
+ + ← {t('log.detail.back')} + + + ← Lobby + +

{log.missionText}

{new Date(log.startedAt).toLocaleString()} diff --git a/src/app/log/page.tsx b/src/app/log/page.tsx index 75a3987..cca9f84 100644 --- a/src/app/log/page.tsx +++ b/src/app/log/page.tsx @@ -22,7 +22,15 @@ export default function LogListPage() { return (
-

{t('log.title')}

+
+

{t('log.title')}

+ + ← Lobby + +
{logs.length === 0 ? (
diff --git a/src/app/page.tsx b/src/app/page.tsx index ae33e61..1a7bfc2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,7 @@ import { LobbyRoutesPanel } from '@/widgets/lobby-routes'; export default function Home() { return ( -
+
diff --git a/src/features/i18n/ui/I18nLayoutShell.tsx b/src/features/i18n/ui/I18nLayoutShell.tsx index fd920fa..95f8958 100644 --- a/src/features/i18n/ui/I18nLayoutShell.tsx +++ b/src/features/i18n/ui/I18nLayoutShell.tsx @@ -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(DEFAULT_LOCALE); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const menuRef = useRef(null); + const menuButtonRef = useRef(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 ( -
-
+
+
FOCUSTELLA - + {isMenuOpen ? ( + + ) : ( + + )} + + {isMenuOpen && ( +
+ +
+ )} +
{children}
diff --git a/src/widgets/flight-hud/ui/FlightHudWidget.tsx b/src/widgets/flight-hud/ui/FlightHudWidget.tsx index 728aff5..a5f529e 100644 --- a/src/widgets/flight-hud/ui/FlightHudWidget.tsx +++ b/src/widgets/flight-hud/ui/FlightHudWidget.tsx @@ -15,6 +15,8 @@ import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store"; import { Voyage, VoyageStatus } from "@/shared/types"; const FINISH_HOLD_MS = 1000; +const HOLD_STAGE_ONE_MS = 100; +const HOLD_STAGE_ONE_PROGRESS = 0.2; type FlightHudWidgetProps = { voyage: Voyage | null; @@ -70,28 +72,52 @@ export function FlightHudWidget({ router.push("/log"); }; - const resetHold = () => { + const stopHoldLoop = () => { if (holdRafRef.current !== null) { cancelAnimationFrame(holdRafRef.current); holdRafRef.current = null; } holdStartAtRef.current = null; + }; + + const resetHold = () => { + stopHoldLoop(); isHoldCompletedRef.current = false; setHoldProgress(0); }; const openDebriefByHold = () => { isHoldCompletedRef.current = true; + stopHoldLoop(); setHoldProgress(1); - openDebriefModal(); - resetHold(); + requestAnimationFrame(() => { + resetHold(); + openDebriefModal(); + }); }; const tickHoldProgress = (timestamp: number) => { if (holdStartAtRef.current === null) return; const elapsed = timestamp - holdStartAtRef.current; - const nextProgress = Math.min(1, elapsed / FINISH_HOLD_MS); + const nextProgress = (() => { + if (elapsed <= HOLD_STAGE_ONE_MS) { + return Math.min( + HOLD_STAGE_ONE_PROGRESS, + (elapsed / HOLD_STAGE_ONE_MS) * HOLD_STAGE_ONE_PROGRESS, + ); + } + + const stageTwoElapsed = elapsed - HOLD_STAGE_ONE_MS; + const stageTwoDuration = FINISH_HOLD_MS - HOLD_STAGE_ONE_MS; + const stageTwoProgressRatio = Math.min(1, stageTwoElapsed / stageTwoDuration); + + return Math.min( + 1, + HOLD_STAGE_ONE_PROGRESS + + stageTwoProgressRatio * (1 - HOLD_STAGE_ONE_PROGRESS), + ); + })(); setHoldProgress(nextProgress); if (nextProgress >= 1) { @@ -107,6 +133,7 @@ export function FlightHudWidget({ resetHold(); holdStartAtRef.current = performance.now(); + setHoldProgress(0); holdRafRef.current = requestAnimationFrame(tickHoldProgress); }; @@ -191,7 +218,7 @@ export function FlightHudWidget({ > diff --git a/src/widgets/lobby-background/ui/LobbyBackgroundWidget.tsx b/src/widgets/lobby-background/ui/LobbyBackgroundWidget.tsx index 2c481e8..7291358 100644 --- a/src/widgets/lobby-background/ui/LobbyBackgroundWidget.tsx +++ b/src/widgets/lobby-background/ui/LobbyBackgroundWidget.tsx @@ -2,7 +2,7 @@ import { ConstellationScene } from '@/features/lobby-starfield'; export function LobbyBackgroundWidget() { return ( -
+
); diff --git a/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx b/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx index b951b84..e34eeca 100644 --- a/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx +++ b/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx @@ -1,5 +1,7 @@ +import { BookOpenText, Languages, Settings } from "lucide-react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { ChangeEvent, useState } from "react"; import { Dialog, @@ -11,6 +13,7 @@ import { import { BoardingMissionForm, startVoyage } from "@/features/boarding"; import { useI18n } from "@/features/i18n/model/useI18n"; import { useLobbyRedirect } from "@/features/lobby-session/model/useLobbyRedirect"; +import { LOCALE_LABELS, Locale, SUPPORTED_LOCALES } from "@/shared/config/i18n"; import { ROUTES } from "@/shared/config/routes"; function RouteCard({ @@ -77,7 +80,7 @@ function RouteCard({ } export function LobbyRoutesPanel() { - const { t } = useI18n(); + const { locale, setLocale, t } = useI18n(); useLobbyRedirect(); const router = useRouter(); const [selectedRouteId, setSelectedRouteId] = useState(null); @@ -106,32 +109,79 @@ export function LobbyRoutesPanel() { router.push("/flight"); }; - return ( -
-
-

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

-

{t("lobby.subtitle")}

-
+ const handleLocaleChange = (event: ChangeEvent) => { + setLocale(event.target.value as Locale); + }; -
-
- + return ( +
+ + +
+
+

+ {t("lobby.title")} +

+

{t("lobby.subtitle")}

-
- {normalRoutes.map((route) => ( +
+
- ))} +
+ +
+ {normalRoutes.map((route) => ( + + ))} +