feat: 항해 종료 시 버튼을 꾹 눌러서 종료
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
## 실행 절차(필수)
|
||||
|
||||
1. 아래 문서를 참고(필요 시)한다:
|
||||
- `@.cli/spec.md`
|
||||
- `@.cli/product/spec.md`
|
||||
- `@.cli/docs/architecture.md`
|
||||
- `@.cli/docs/rules.md`
|
||||
|
||||
|
||||
@@ -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">
|
||||
← {t('log.detail.back')}
|
||||
</Link>
|
||||
<div className="mb-4 flex items-center gap-4 text-sm">
|
||||
<Link href="/log" className="text-indigo-400 transition-colors hover:text-indigo-300">
|
||||
← {t('log.detail.back')}
|
||||
</Link>
|
||||
<Link href="/" className="text-indigo-400 transition-colors hover:text-indigo-300">
|
||||
← 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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,32 +109,79 @@ export function LobbyRoutesPanel() {
|
||||
router.push("/flight");
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setLocale(event.target.value as Locale);
|
||||
};
|
||||
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<div className="w-full">
|
||||
<RouteCard
|
||||
route={stationRoute}
|
||||
isCTA={true}
|
||||
onLaunch={handleOpenBoarding}
|
||||
/>
|
||||
return (
|
||||
<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="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{normalRoutes.map((route) => (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-8">
|
||||
<div className="w-full">
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
route={route}
|
||||
route={stationRoute}
|
||||
isCTA={true}
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user