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

@@ -8,7 +8,7 @@
## 실행 절차(필수) ## 실행 절차(필수)
1. 아래 문서를 참고(필요 시)한다: 1. 아래 문서를 참고(필요 시)한다:
- `@.cli/spec.md` - `@.cli/product/spec.md`
- `@.cli/docs/architecture.md` - `@.cli/docs/architecture.md`
- `@.cli/docs/rules.md` - `@.cli/docs/rules.md`

View File

@@ -31,9 +31,14 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
return ( return (
<div className="flex flex-col flex-1 p-6 space-y-8 animate-in fade-in duration-300"> <div className="flex flex-col flex-1 p-6 space-y-8 animate-in fade-in duration-300">
<header className="border-b border-slate-800 pb-4"> <header className="border-b border-slate-800 pb-4">
<Link href="/log" className="text-sm text-indigo-400 hover:text-indigo-300 mb-4 inline-block"> <div className="mb-4 flex items-center gap-4 text-sm">
&larr; {t('log.detail.back')} <Link href="/log" className="text-indigo-400 transition-colors hover:text-indigo-300">
</Link> &larr; {t('log.detail.back')}
</Link>
<Link href="/" className="text-indigo-400 transition-colors hover:text-indigo-300">
&larr; Lobby
</Link>
</div>
<h1 className="text-2xl font-bold text-white">{log.missionText}</h1> <h1 className="text-2xl font-bold text-white">{log.missionText}</h1>
<div className="flex gap-3 mt-2 text-sm text-slate-500"> <div className="flex gap-3 mt-2 text-sm text-slate-500">
<span>{new Date(log.startedAt).toLocaleString()}</span> <span>{new Date(log.startedAt).toLocaleString()}</span>

View File

@@ -22,7 +22,15 @@ export default function LogListPage() {
return ( return (
<div className="flex flex-col flex-1 p-6"> <div className="flex flex-col flex-1 p-6">
<h1 className="text-xl font-bold text-slate-100 mb-6">{t('log.title')}</h1> <div className="mb-6 flex items-center justify-between gap-3">
<h1 className="text-xl font-bold text-slate-100">{t('log.title')}</h1>
<Link
href="/"
className="text-sm text-indigo-400 transition-colors hover:text-indigo-300"
>
&larr; Lobby
</Link>
</div>
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="flex flex-col items-center justify-center flex-1 py-20 text-slate-500 border border-dashed border-slate-800 rounded-xl"> <div className="flex flex-col items-center justify-center flex-1 py-20 text-slate-500 border border-dashed border-slate-800 rounded-xl">

View File

@@ -5,7 +5,7 @@ import { LobbyRoutesPanel } from '@/widgets/lobby-routes';
export default function Home() { export default function Home() {
return ( return (
<div className="relative flex flex-1 flex-col animate-in fade-in duration-500"> <div className="relative left-1/2 right-1/2 -mx-[50vw] flex w-screen flex-1 flex-col animate-in fade-in duration-500">
<LobbyBackgroundWidget /> <LobbyBackgroundWidget />
<LobbyRoutesPanel /> <LobbyRoutesPanel />
</div> </div>

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { Menu as MenuIcon, X as CloseIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ChangeEvent, useEffect, useState } from "react"; import { ChangeEvent, useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
@@ -22,7 +24,12 @@ export function I18nLayoutShell({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pathname = usePathname();
const isLobby = pathname === "/";
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE); 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(() => { useEffect(() => {
const initialLocale = resolveInitialLocale(); const initialLocale = resolveInitialLocale();
@@ -44,41 +51,115 @@ export function I18nLayoutShell({
const t = (key: I18nKey) => translateText(locale, key); 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 ( return (
<I18nProvider locale={locale} setLocale={handleSetLocale}> <I18nProvider locale={locale} setLocale={handleSetLocale}>
<div className="relative z-10 mx-auto flex min-h-screen max-w-6xl flex-col"> <div
<header className="flex items-center justify-between px-6 py-4"> 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 <Link
href="/" href="/"
className="z-50 text-lg font-bold tracking-wider text-indigo-400 transition-colors hover:text-indigo-300" className="z-50 text-lg font-bold tracking-wider text-indigo-400 transition-colors hover:text-indigo-300"
> >
FOCUSTELLA FOCUSTELLA
</Link> </Link>
<nav className="z-50 flex items-center gap-4 text-sm font-medium text-slate-400"> <div
<Link href="/log" className="transition-colors hover:text-slate-200"> ref={menuRef}
{t("layout.nav.log")} className="relative z-50"
</Link> onBlur={(event) => {
<Link if (!isMenuOpen) return;
href="/settings" const nextFocused = event.relatedTarget as Node | null;
className="transition-colors hover:text-slate-200" 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")} {isMenuOpen ? (
</Link> <CloseIcon className="h-5 w-5" aria-hidden />
<label className="flex items-center gap-2 text-xs text-slate-400"> ) : (
<span>{t("layout.nav.language")}</span> <MenuIcon className="h-5 w-5" aria-hidden />
<select )}
value={locale} </button>
onChange={handleLocaleChange} {isMenuOpen && (
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" <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">
{SUPPORTED_LOCALES.map((item) => ( <Link
<option key={item} value={item}> href="/log"
{LOCALE_LABELS[item]} className="rounded-md px-3 py-2 transition-colors hover:bg-slate-800 hover:text-slate-100"
</option> >
))} {t("layout.nav.log")}
</select> </Link>
</label> <Link
</nav> 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> </header>
<main className="relative flex w-full flex-1 flex-col">{children}</main> <main className="relative flex w-full flex-1 flex-col">{children}</main>
</div> </div>

View File

@@ -15,6 +15,8 @@ import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
import { Voyage, VoyageStatus } from "@/shared/types"; import { Voyage, VoyageStatus } from "@/shared/types";
const FINISH_HOLD_MS = 1000; const FINISH_HOLD_MS = 1000;
const HOLD_STAGE_ONE_MS = 100;
const HOLD_STAGE_ONE_PROGRESS = 0.2;
type FlightHudWidgetProps = { type FlightHudWidgetProps = {
voyage: Voyage | null; voyage: Voyage | null;
@@ -70,28 +72,52 @@ export function FlightHudWidget({
router.push("/log"); router.push("/log");
}; };
const resetHold = () => { const stopHoldLoop = () => {
if (holdRafRef.current !== null) { if (holdRafRef.current !== null) {
cancelAnimationFrame(holdRafRef.current); cancelAnimationFrame(holdRafRef.current);
holdRafRef.current = null; holdRafRef.current = null;
} }
holdStartAtRef.current = null; holdStartAtRef.current = null;
};
const resetHold = () => {
stopHoldLoop();
isHoldCompletedRef.current = false; isHoldCompletedRef.current = false;
setHoldProgress(0); setHoldProgress(0);
}; };
const openDebriefByHold = () => { const openDebriefByHold = () => {
isHoldCompletedRef.current = true; isHoldCompletedRef.current = true;
stopHoldLoop();
setHoldProgress(1); setHoldProgress(1);
openDebriefModal(); requestAnimationFrame(() => {
resetHold(); resetHold();
openDebriefModal();
});
}; };
const tickHoldProgress = (timestamp: number) => { const tickHoldProgress = (timestamp: number) => {
if (holdStartAtRef.current === null) return; if (holdStartAtRef.current === null) return;
const elapsed = timestamp - holdStartAtRef.current; 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); setHoldProgress(nextProgress);
if (nextProgress >= 1) { if (nextProgress >= 1) {
@@ -107,6 +133,7 @@ export function FlightHudWidget({
resetHold(); resetHold();
holdStartAtRef.current = performance.now(); holdStartAtRef.current = performance.now();
setHoldProgress(0);
holdRafRef.current = requestAnimationFrame(tickHoldProgress); holdRafRef.current = requestAnimationFrame(tickHoldProgress);
}; };
@@ -191,7 +218,7 @@ export function FlightHudWidget({
> >
<span <span
aria-hidden aria-hidden
className="absolute inset-0 origin-left rounded-full bg-indigo-400/45 transition-transform duration-75" className="absolute inset-0 origin-left bg-indigo-400/45"
style={{ transform: `scaleX(${holdProgress})` }} style={{ transform: `scaleX(${holdProgress})` }}
/> />
<span className="relative z-10"> <span className="relative z-10">

View File

@@ -2,7 +2,7 @@ import { ConstellationScene } from '@/features/lobby-starfield';
export function LobbyBackgroundWidget() { export function LobbyBackgroundWidget() {
return ( return (
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black"> <div className="pointer-events-none fixed inset-0 z-0 overflow-hidden bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black lg:left-72">
<ConstellationScene /> <ConstellationScene />
</div> </div>
); );

View File

@@ -1,5 +1,7 @@
import { BookOpenText, Languages, Settings } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { ChangeEvent, useState } from "react";
import { import {
Dialog, Dialog,
@@ -11,6 +13,7 @@ import {
import { BoardingMissionForm, startVoyage } from "@/features/boarding"; import { BoardingMissionForm, startVoyage } from "@/features/boarding";
import { useI18n } from "@/features/i18n/model/useI18n"; import { useI18n } from "@/features/i18n/model/useI18n";
import { useLobbyRedirect } from "@/features/lobby-session/model/useLobbyRedirect"; import { useLobbyRedirect } from "@/features/lobby-session/model/useLobbyRedirect";
import { LOCALE_LABELS, Locale, SUPPORTED_LOCALES } from "@/shared/config/i18n";
import { ROUTES } from "@/shared/config/routes"; import { ROUTES } from "@/shared/config/routes";
function RouteCard({ function RouteCard({
@@ -77,7 +80,7 @@ function RouteCard({
} }
export function LobbyRoutesPanel() { export function LobbyRoutesPanel() {
const { t } = useI18n(); const { locale, setLocale, t } = useI18n();
useLobbyRedirect(); useLobbyRedirect();
const router = useRouter(); const router = useRouter();
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null); const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
@@ -106,32 +109,79 @@ export function LobbyRoutesPanel() {
router.push("/flight"); router.push("/flight");
}; };
return ( const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
<div className="relative z-10 flex min-h-[calc(100vh-80px)] flex-col items-center justify-center p-6 md:p-12"> setLocale(event.target.value as Locale);
<div className="mx-auto mb-12 max-w-2xl space-y-4 rounded-3xl border border-slate-800/30 bg-slate-950/30 p-6 text-center backdrop-blur-sm"> };
<h1 className="text-3xl font-bold tracking-tight text-slate-100 md:text-5xl">
{t("lobby.title")}
</h1>
<p className="text-lg text-slate-300">{t("lobby.subtitle")}</p>
</div>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6"> return (
<div className="w-full"> <div className="relative z-10 flex min-h-[calc(100vh-80px)] w-full flex-col p-6 md:p-12 lg:min-h-screen lg:flex-row lg:items-stretch lg:gap-0 lg:p-0">
<RouteCard <aside className="hidden w-72 shrink-0 border-r border-slate-700/80 bg-slate-800/65 px-7 py-8 shadow-[2px_0_24px_rgba(2,6,23,0.45)] backdrop-blur lg:flex lg:min-h-screen lg:flex-col lg:justify-between">
route={stationRoute} <div className="flex flex-col gap-10">
isCTA={true} <h1 className="text-lg font-bold tracking-[0.14em] text-indigo-300">
onLaunch={handleOpenBoarding} FOCUSTELLA
/> </h1>
<nav className="flex flex-col gap-2">
<Link
href="/log"
className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm font-semibold text-slate-300 transition-colors hover:border-slate-700 hover:bg-slate-900/70 hover:text-slate-100"
>
<BookOpenText className="h-4 w-4 text-indigo-300" aria-hidden />
<span>{t("layout.nav.log")}</span>
</Link>
<Link
href="/settings"
className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm font-semibold text-slate-300 transition-colors hover:border-slate-700 hover:bg-slate-900/70 hover:text-slate-100"
>
<Settings className="h-4 w-4 text-indigo-300" aria-hidden />
<span>{t("layout.nav.settings")}</span>
</Link>
</nav>
</div>
<label className="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
<span className="inline-flex items-center gap-2">
<Languages className="h-4 w-4 text-indigo-300" aria-hidden />
{t("layout.nav.language")}
</span>
<select
value={locale}
onChange={handleLocaleChange}
className="rounded border border-slate-700 bg-slate-900/80 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>
</aside>
<div className="w-full flex-1 lg:min-h-screen lg:bg-slate-950/68 lg:px-10 lg:pt-10 lg:shadow-[inset_0_1px_0_rgba(148,163,184,0.06)]">
<div className="mx-auto mb-12 max-w-2xl space-y-4 rounded-3xl border border-slate-800/40 bg-slate-950/35 p-6 text-center backdrop-blur-sm">
<h1 className="text-3xl font-bold tracking-tight text-slate-100 md:text-5xl">
{t("lobby.title")}
</h1>
<p className="text-lg text-slate-300">{t("lobby.subtitle")}</p>
</div> </div>
<div className="grid w-full grid-cols-1 gap-6 md:grid-cols-2"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-8">
{normalRoutes.map((route) => ( <div className="w-full">
<RouteCard <RouteCard
key={route.id} route={stationRoute}
route={route} isCTA={true}
onLaunch={handleOpenBoarding} onLaunch={handleOpenBoarding}
/> />
))} </div>
<div className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
{normalRoutes.map((route) => (
<RouteCard
key={route.id}
route={route}
onLaunch={handleOpenBoarding}
/>
))}
</div>
</div> </div>
</div> </div>