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. 아래 문서를 참고(필요 시)한다:
- `@.cli/spec.md`
- `@.cli/product/spec.md`
- `@.cli/docs/architecture.md`
- `@.cli/docs/rules.md`

View File

@@ -31,9 +31,14 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
return (
<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">
<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">
<Link href="/log" className="text-indigo-400 transition-colors hover:text-indigo-300">
&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>
<div className="flex gap-3 mt-2 text-sm text-slate-500">
<span>{new Date(log.startedAt).toLocaleString()}</span>

View File

@@ -22,7 +22,15 @@ export default function LogListPage() {
return (
<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 ? (
<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() {
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 />
<LobbyRoutesPanel />
</div>

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,27 +51,97 @@ 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">
<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="transition-colors hover:text-slate-200"
className="rounded-md px-3 py-2 transition-colors hover:bg-slate-800 hover:text-slate-100"
>
{t("layout.nav.settings")}
</Link>
<label className="flex items-center gap-2 text-xs text-slate-400">
<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}
@@ -78,7 +155,11 @@ export function I18nLayoutShell({
))}
</select>
</label>
</div>
</nav>
</div>
)}
</div>
</header>
<main className="relative flex w-full flex-1 flex-col">{children}</main>
</div>

View File

@@ -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();
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({
>
<span
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})` }}
/>
<span className="relative z-10">

View File

@@ -2,7 +2,7 @@ import { ConstellationScene } from '@/features/lobby-starfield';
export function LobbyBackgroundWidget() {
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 />
</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 { 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<string | null>(null);
@@ -106,16 +109,62 @@ export function LobbyRoutesPanel() {
router.push("/flight");
};
const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
setLocale(event.target.value as Locale);
};
return (
<div className="relative z-10 flex min-h-[calc(100vh-80px)] flex-col items-center justify-center p-6 md:p-12">
<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">
<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">
<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">
<div className="flex flex-col gap-10">
<h1 className="text-lg font-bold tracking-[0.14em] text-indigo-300">
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 className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-8">
<div className="w-full">
<RouteCard
route={stationRoute}
@@ -134,6 +183,7 @@ export function LobbyRoutesPanel() {
))}
</div>
</div>
</div>
<Dialog open={isBoardingOpen} onOpenChange={setIsBoardingOpen}>
<DialogContent