feat: 다국어 화

This commit is contained in:
2026-02-14 03:56:03 +09:00
parent efdec596b2
commit 166d04384f
17 changed files with 691 additions and 160 deletions

View File

@@ -71,6 +71,18 @@
- `/boarding` 라우트: 딥링크 호환을 위해 동일 form/model 재사용 - `/boarding` 라우트: 딥링크 호환을 위해 동일 form/model 재사용
- 메모/노트 입력은 탑승 생성 경로에서 제거됨 - 메모/노트 입력은 탑승 생성 경로에서 제거됨
## I18n 소유권 (1단계)
- 지원 언어/기본값/카피 상수: `shared/config/i18n.ts`
- 초기 언어 결정/수동 고정 저장: `features/i18n/model/resolveInitialLocale.ts`
- 런타임 번역 접근(context/hook): `features/i18n/model/useI18n.tsx`
- 앱 초기 bootstrap + 수동 변경 UI(헤더 select): `features/i18n/ui/I18nLayoutShell.tsx`
- `app/layout.tsx`는 i18n shell을 마운트해 단일 URL에서 언어 상태만 관리한다
- 우선순위: `수동 저장값(localStorage) > 브라우저 언어 > en`
- 지원 외 언어는 `en`으로 폴백한다
- 페이지/UI 문구는 key(`lobby.*`, `flight.*`, `debrief.*`, `log.*`, `settings.*`, `routes.*`)로 관리한다
- 항로 메타(`shared/config/routes.ts`)는 사용자 노출 문자열 대신 i18n key를 소유한다
## 변경 정책 ## 변경 정책
- 구조 리팩토링은 명시적으로 요청되지 않는 한 동작을 바꾸면 안 된다 - 구조 리팩토링은 명시적으로 요청되지 않는 한 동작을 바꾸면 안 된다

View File

@@ -4,15 +4,18 @@ import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { ROUTES } from '@/shared/config/routes'; import { ROUTES } from '@/shared/config/routes';
import { BoardingMissionForm, startVoyage } from '@/features/boarding'; import { BoardingMissionForm, startVoyage } from '@/features/boarding';
import { useI18n } from '@/features/i18n/model/useI18n';
function BoardingContent() { function BoardingContent() {
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const routeId = searchParams.get('routeId'); const routeId = searchParams.get('routeId');
const route = ROUTES.find(r => r.id === routeId) || ROUTES[0]; const route = ROUTES.find(r => r.id === routeId) || ROUTES[0];
const routeName = t(route.nameKey, undefined, route.id);
const handleDocking = (mission: string) => { const handleDocking = (mission: string) => {
const started = startVoyage({ route, mission }); const started = startVoyage({ route, mission, routeName });
if (!started) return; if (!started) return;
router.push('/flight'); router.push('/flight');
}; };
@@ -20,8 +23,12 @@ function BoardingContent() {
return ( return (
<div className="flex flex-col flex-1 p-6 animate-in slide-in-from-bottom-4 duration-500"> <div className="flex flex-col flex-1 p-6 animate-in slide-in-from-bottom-4 duration-500">
<div className="mb-8"> <div className="mb-8">
<h2 className="text-sm text-indigo-400 font-semibold mb-1 uppercase tracking-widest">Boarding Check</h2> <h2 className="text-sm text-indigo-400 font-semibold mb-1 uppercase tracking-widest">
<h1 className="text-3xl font-bold text-white">{route.name} </h1> {t('boarding.check')}
</h2>
<h1 className="text-3xl font-bold text-white">
{t('boarding.routeBoarding', { routeName })}
</h1>
</div> </div>
<div className="space-y-8 flex-1"> <div className="space-y-8 flex-1">
@@ -35,8 +42,10 @@ function BoardingContent() {
} }
export default function BoardingPage() { export default function BoardingPage() {
const { t } = useI18n();
return ( return (
<Suspense fallback={<div className="p-6">Loading...</div>}> <Suspense fallback={<div className="p-6">{t('common.loading')}</div>}>
<BoardingContent /> <BoardingContent />
</Suspense> </Suspense>
); );

View File

@@ -2,10 +2,13 @@
import { FormEvent, useEffect, useState } from 'react'; import { FormEvent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useI18n } from '@/features/i18n/model/useI18n';
import { DEBRIEF_STATUS_OPTIONS } from '@/shared/config/i18n';
import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/shared/lib/store'; import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/shared/lib/store';
import { Voyage, VoyageStatus } from '@/shared/types'; import { Voyage, VoyageStatus } from '@/shared/types';
export default function DebriefPage() { export default function DebriefPage() {
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const [voyage, setVoyage] = useState<Voyage | null>(null); const [voyage, setVoyage] = useState<Voyage | null>(null);
@@ -43,54 +46,58 @@ export default function DebriefPage() {
if (!voyage) return null; if (!voyage) return null;
const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [ const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
{ value: 'completed', label: '계획대로', desc: '목표를 달성했습니다' }, value: option.value as VoyageStatus,
{ value: 'partial', label: '부분 진행', desc: '절반의 성공입니다' }, label: t(option.labelKey),
{ value: 'reoriented', label: '방향 재설정', desc: '새로운 발견을 했습니다' }, desc: t(option.descKey),
]; }));
return ( return (
<div className="flex flex-col flex-1 p-6 max-w-2xl mx-auto w-full animate-in zoom-in-95 duration-500"> <div className="flex flex-col flex-1 p-6 max-w-2xl mx-auto w-full animate-in zoom-in-95 duration-500">
<header className="mb-8 text-center"> <header className="mb-8 text-center">
<h1 className="text-2xl font-bold text-white mb-2"> </h1> <h1 className="text-2xl font-bold text-white mb-2">{t('debrief.page.title')}</h1>
<p className="text-slate-400"> .</p> <p className="text-slate-400">{t('debrief.page.description')}</p>
</header> </header>
<form onSubmit={handleSubmit} className="space-y-8 flex-1"> <form onSubmit={handleSubmit} className="space-y-8 flex-1">
{/* Question 1: Status */} {/* Question 1: Status */}
<section> <section>
<label className="block text-sm font-medium text-slate-300 mb-3"> <label className="block text-sm font-medium text-slate-300 mb-3">
{t('debrief.status.label')}
</label> </label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{statusOptions.map((opt) => ( {statusOptions.map((opt) => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
onClick={() => setStatus(opt.value)} onClick={() => setStatus(opt.value)}
className={`p-4 rounded-xl border text-left transition-all ${ className={`flex min-h-[116px] flex-col justify-between rounded-xl border px-4 py-3.5 text-left transition-all ${
status === opt.value status === opt.value
? 'bg-indigo-900/40 border-indigo-500 ring-1 ring-indigo-500' ? 'bg-indigo-900/40 border-indigo-500 ring-1 ring-indigo-500'
: 'bg-slate-900/50 border-slate-800 hover:bg-slate-800' : 'bg-slate-900/50 border-slate-800 hover:bg-slate-800'
}`} }`}
> >
<div className="font-bold text-slate-200 mb-1">{opt.label}</div> <div className="text-sm leading-snug font-bold text-slate-200 break-keep">
<div className="text-xs text-slate-500">{opt.desc}</div> {opt.label}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-slate-500">
{opt.desc}
</div>
</button> </button>
))} ))}
</div> </div>
</section> </section>
{/* Question 2: Secured */} {/* Question 2: Reflection */}
<section> <section>
<label className="block text-sm font-medium text-slate-300 mb-2"> <label className="block text-sm font-medium text-slate-300 mb-2">
(What) {t('debrief.reflection.label')}
</label> </label>
<input <input
type="text" type="text"
value={progress} value={progress}
onChange={(e) => setProgress(e.target.value)} onChange={(e) => setProgress(e.target.value)}
placeholder="예: 기획안 목차 구성 완료" placeholder={t('debrief.reflection.placeholder')}
className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all" className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
/> />
</section> </section>
@@ -100,7 +107,7 @@ export default function DebriefPage() {
disabled={!status} disabled={!status}
className="w-full mt-10 py-4 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/20" className="w-full mt-10 py-4 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/20"
> >
{t('debrief.save')}
</button> </button>
</form> </form>
</div> </div>

View File

@@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import Link from "next/link"; import { I18nLayoutShell } from "@/features/i18n/ui/I18nLayoutShell";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Focustella", title: "Focustella",
@@ -13,23 +13,9 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="ko" className="dark"> <html lang="en" className="dark">
<body className="bg-slate-950 text-slate-100 min-h-screen font-sans selection:bg-indigo-500/30 overflow-x-hidden"> <body className="bg-slate-950 text-slate-100 min-h-screen font-sans selection:bg-indigo-500/30 overflow-x-hidden">
{/* Layout Container */} <I18nLayoutShell>{children}</I18nLayoutShell>
<div className="relative z-10 min-h-screen flex flex-col max-w-6xl mx-auto">
<header className="flex items-center justify-between px-6 py-4">
<Link href="/" className="font-bold text-lg tracking-wider text-indigo-400 hover:text-indigo-300 transition-colors z-50">
FOCUSTELLA
</Link>
<nav className="flex gap-4 text-sm font-medium text-slate-400 z-50">
<Link href="/log" className="hover:text-slate-200 transition-colors"></Link>
<Link href="/settings" className="hover:text-slate-200 transition-colors"></Link>
</nav>
</header>
<main className="flex-1 relative flex flex-col w-full">
{children}
</main>
</div>
</body> </body>
</html> </html>
); );

View File

@@ -1,15 +1,17 @@
'use client'; 'use client';
import { useEffect, useState, use } from 'react'; import { useEffect, useState, use } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useI18n } from '@/features/i18n/model/useI18n';
import { VOYAGE_STATUS_LABEL_KEYS } from '@/shared/config/i18n';
import { findRouteById } from '@/shared/config/routes';
import { getHistory } from '@/shared/lib/store'; import { getHistory } from '@/shared/lib/store';
import { Voyage } from '@/shared/types'; import { Voyage } from '@/shared/types';
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { t } = useI18n();
// Next.js 15: params is a Promise // Next.js 15: params is a Promise
const resolvedParams = use(params); const resolvedParams = use(params);
const router = useRouter();
const [log, setLog] = useState<Voyage | null>(null); const [log, setLog] = useState<Voyage | null>(null);
useEffect(() => { useEffect(() => {
@@ -18,47 +20,49 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
if (found) setLog(found); if (found) setLog(found);
}, [resolvedParams.id]); }, [resolvedParams.id]);
if (!log) return <div className="p-6 text-slate-500">Loading or Not Found...</div>; if (!log) {
return <div className="p-6 text-slate-500">{t('log.detail.loadingOrNotFound')}</div>;
}
const route = findRouteById(log.routeId);
const routeName = route ? t(route.nameKey, undefined, log.routeName) : log.routeName;
const statusLabel = t(VOYAGE_STATUS_LABEL_KEYS[log.status], undefined, t('status.in_progress'));
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">&larr; </Link> <Link href="/log" className="text-sm text-indigo-400 hover:text-indigo-300 mb-4 inline-block">
&larr; {t('log.detail.back')}
</Link>
<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>
<span></span> <span></span>
<span>{log.routeName} ({log.durationMinutes}m)</span> <span>{routeName} ({log.durationMinutes}{t('common.minuteShort')})</span>
</div> </div>
</header> </header>
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-slate-900/30 p-4 rounded-lg border border-slate-800/50"> <div className="bg-slate-900/30 p-4 rounded-lg border border-slate-800/50">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2"> </h3> <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.statusTitle')}</h3>
<p className="text-lg text-indigo-100"> <p className="text-lg text-indigo-100">{statusLabel}</p>
{log.status === 'completed' && '✅ 계획대로'}
{log.status === 'partial' && '🌓 부분 진행'}
{log.status === 'reoriented' && '🧭 방향 재설정'}
{log.status === 'aborted' && '🚨 조기 귀환'}
{log.status === 'in_progress' && '진행 중'}
</p>
</div> </div>
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
<div> <div>
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2"> </h3> <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.progressTitle')}</h3>
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.debriefProgress || '-'}</p> <p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.debriefProgress || '-'}</p>
</div> </div>
<div> <div>
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2"> </h3> <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.nextActionTitle')}</h3>
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.nextAction || '-'}</p> <p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.nextAction || '-'}</p>
</div> </div>
</div> </div>
{log.notes && ( {log.notes && (
<div> <div>
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2"> </h3> <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.initialNoteTitle')}</h3>
<p className="text-slate-400 text-sm italic">"{log.notes}"</p> <p className="text-slate-400 text-sm italic">"{log.notes}"</p>
</div> </div>
)} )}

View File

@@ -2,10 +2,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useI18n } from '@/features/i18n/model/useI18n';
import { VOYAGE_STATUS_LABEL_KEYS } from '@/shared/config/i18n';
import { findRouteById } from '@/shared/config/routes';
import { getHistory } from '@/shared/lib/store'; import { getHistory } from '@/shared/lib/store';
import { Voyage, VoyageStatus } from '@/shared/types'; import { Voyage, VoyageStatus } from '@/shared/types';
export default function LogListPage() { export default function LogListPage() {
const { t } = useI18n();
const [logs, setLogs] = useState<Voyage[]>([]); const [logs, setLogs] = useState<Voyage[]>([]);
useEffect(() => { useEffect(() => {
@@ -13,48 +17,52 @@ export default function LogListPage() {
}, []); }, []);
const getStatusLabel = (s: VoyageStatus) => { const getStatusLabel = (s: VoyageStatus) => {
switch(s) { return t(VOYAGE_STATUS_LABEL_KEYS[s], undefined, t('status.in_progress'));
case 'completed': return '✅ 계획대로';
case 'partial': return '🌓 부분 진행';
case 'reoriented': return '🧭 방향 재설정';
case 'aborted': return '🚨 조기 귀환';
default: return '진행 중';
}
}; };
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"> </h1> <h1 className="text-xl font-bold text-slate-100 mb-6">{t('log.title')}</h1>
{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">
<p className="mb-4"> .</p> <p className="mb-4">{t('log.empty')}</p>
<Link href="/" className="text-indigo-400 hover:text-indigo-300 underline"> <Link href="/" className="text-indigo-400 hover:text-indigo-300 underline">
{t('log.firstVoyage')}
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="grid gap-3"> <div className="grid gap-3">
{logs.map((log) => ( {logs.map((log) => {
<Link const route = findRouteById(log.routeId);
key={log.id} const routeName = route
href={`/log/${log.id}`} ? t(route.nameKey, undefined, log.routeName)
className="block bg-slate-900/50 border border-slate-800 hover:border-slate-600 rounded-lg p-4 transition-colors" : log.routeName;
>
<div className="flex justify-between items-start mb-1"> return (
<span className="text-xs text-slate-500"> <Link
{new Date(log.startedAt).toLocaleDateString()} key={log.id}
</span> href={`/log/${log.id}`}
<span className="text-xs font-medium text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded"> className="block bg-slate-900/50 border border-slate-800 hover:border-slate-600 rounded-lg p-4 transition-colors"
{getStatusLabel(log.status)} >
</span> <div className="flex justify-between items-start mb-1">
</div> <span className="text-xs text-slate-500">
<h3 className="font-semibold text-slate-200 truncate mb-1"> {new Date(log.startedAt).toLocaleDateString()}
{log.missionText} </span>
</h3> <span className="text-xs font-medium text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded">
<p className="text-xs text-slate-500">{log.routeName} · {log.durationMinutes}min</p> {getStatusLabel(log.status)}
</Link> </span>
))} </div>
<h3 className="font-semibold text-slate-200 truncate mb-1">
{log.missionText}
</h3>
<p className="text-xs text-slate-500">
{routeName} · {log.durationMinutes}
{t('common.minuteShort')}
</p>
</Link>
);
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useI18n } from '@/features/i18n/model/useI18n';
import { getPreferences, savePreferences } from '@/shared/lib/store'; import { getPreferences, savePreferences } from '@/shared/lib/store';
export default function SettingsPage() { export default function SettingsPage() {
const { t } = useI18n();
const [hideSeconds, setHideSeconds] = useState(false); const [hideSeconds, setHideSeconds] = useState(false);
useEffect(() => { useEffect(() => {
@@ -19,13 +21,13 @@ export default function SettingsPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<h1 className="text-xl font-bold text-slate-100 mb-8"></h1> <h1 className="text-xl font-bold text-slate-100 mb-8">{t('settings.title')}</h1>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between bg-slate-900/40 p-4 rounded-xl border border-slate-800"> <div className="flex items-center justify-between bg-slate-900/40 p-4 rounded-xl border border-slate-800">
<div> <div>
<h3 className="font-medium text-slate-200"> </h3> <h3 className="font-medium text-slate-200">{t('settings.hideSeconds.title')}</h3>
<p className="text-xs text-slate-500"> .</p> <p className="text-xs text-slate-500">{t('settings.hideSeconds.description')}</p>
</div> </div>
<button <button
onClick={handleToggle} onClick={handleToggle}

View File

@@ -9,9 +9,11 @@ const createVoyageId = () =>
export const startVoyage = ({ export const startVoyage = ({
route, route,
mission, mission,
routeName,
}: { }: {
route: Route; route: Route;
mission: string; mission: string;
routeName: string;
}) => { }) => {
const missionText = mission.trim(); const missionText = mission.trim();
if (!missionText) { if (!missionText) {
@@ -21,7 +23,7 @@ export const startVoyage = ({
const newVoyage: Voyage = { const newVoyage: Voyage = {
id: createVoyageId(), id: createVoyageId(),
routeId: route.id, routeId: route.id,
routeName: route.name, routeName,
durationMinutes: route.durationMinutes, durationMinutes: route.durationMinutes,
startedAt: Date.now(), startedAt: Date.now(),
status: 'in_progress', status: 'in_progress',

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { FormEvent, useState } from 'react'; import { FormEvent, useState } from 'react';
import { useI18n } from '@/features/i18n/model/useI18n';
export function BoardingMissionForm({ export function BoardingMissionForm({
onDock, onDock,
@@ -13,6 +14,7 @@ export function BoardingMissionForm({
autoFocus?: boolean; autoFocus?: boolean;
compact?: boolean; compact?: boolean;
}) { }) {
const { t } = useI18n();
const [mission, setMission] = useState(''); const [mission, setMission] = useState('');
const trimmedMission = mission.trim(); const trimmedMission = mission.trim();
const canSubmit = Boolean(trimmedMission); const canSubmit = Boolean(trimmedMission);
@@ -30,13 +32,13 @@ export function BoardingMissionForm({
> >
<div className="space-y-3"> <div className="space-y-3">
<label className="block text-sm font-medium text-slate-300"> <label className="block text-sm font-medium text-slate-300">
{t('boarding.missionLabel')}
</label> </label>
<input <input
type="text" type="text"
value={mission} value={mission}
onChange={(event) => setMission(event.target.value)} onChange={(event) => setMission(event.target.value)}
placeholder="예: 서론 3문단 완성하기" placeholder={t('boarding.missionPlaceholder')}
className="w-full border-b-2 border-slate-700 bg-slate-900/50 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600 focus:border-indigo-500" className="w-full border-b-2 border-slate-700 bg-slate-900/50 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600 focus:border-indigo-500"
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
@@ -49,7 +51,7 @@ export function BoardingMissionForm({
onClick={onCancel} onClick={onCancel}
className="rounded-xl border border-slate-700 bg-transparent px-4 py-2 font-semibold text-slate-300 transition-colors hover:bg-slate-900/60 hover:text-white" className="rounded-xl border border-slate-700 bg-transparent px-4 py-2 font-semibold text-slate-300 transition-colors hover:bg-slate-900/60 hover:text-white"
> >
{t('boarding.cancel')}
</button> </button>
)} )}
<button <button
@@ -57,7 +59,7 @@ export function BoardingMissionForm({
disabled={!canSubmit} disabled={!canSubmit}
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`} className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
> >
() {t('boarding.submit')}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,42 @@
import {
DEFAULT_LOCALE,
Locale,
MANUAL_LOCALE_STORAGE_KEY,
SUPPORTED_LOCALES,
} from "@/shared/config/i18n";
const normalizeLocale = (raw: string | null | undefined): Locale | null => {
if (!raw) return null;
const base = raw.trim().toLowerCase().split("-")[0];
return SUPPORTED_LOCALES.includes(base as Locale) ? (base as Locale) : null;
};
export const getManualLocale = (): Locale | null => {
if (typeof window === "undefined") return null;
return normalizeLocale(localStorage.getItem(MANUAL_LOCALE_STORAGE_KEY));
};
const getBrowserLocale = (): Locale | null => {
if (typeof navigator === "undefined") return null;
const candidates = [...(navigator.languages ?? []), navigator.language];
for (const candidate of candidates) {
const locale = normalizeLocale(candidate);
if (locale) return locale;
}
return null;
};
export const resolveInitialLocale = (): Locale => {
const manualLocale = getManualLocale();
if (manualLocale) return manualLocale;
return getBrowserLocale() ?? DEFAULT_LOCALE;
};
export const saveManualLocale = (locale: Locale) => {
if (typeof window === "undefined") return;
localStorage.setItem(MANUAL_LOCALE_STORAGE_KEY, locale);
};

View File

@@ -0,0 +1,45 @@
"use client";
import { ReactNode, createContext, useContext } from "react";
import {
DEFAULT_LOCALE,
I18nKey,
Locale,
TranslationParams,
translateText,
} from "@/shared/config/i18n";
type I18nContextValue = {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: I18nKey | string, params?: TranslationParams, fallback?: string) => string;
};
const I18nContext = createContext<I18nContextValue>({
locale: DEFAULT_LOCALE,
setLocale: () => {},
t: (key, params, fallback) =>
translateText(DEFAULT_LOCALE, key, params, fallback),
});
export function I18nProvider({
children,
locale,
setLocale,
}: {
children: ReactNode;
locale: Locale;
setLocale: (locale: Locale) => void;
}) {
const t = (key: I18nKey | string, params?: TranslationParams, fallback?: string) =>
translateText(locale, key, params, fallback);
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
}
export const useI18n = () => useContext(I18nContext);

View File

@@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { ChangeEvent, useEffect, useState } from "react";
import {
DEFAULT_LOCALE,
I18nKey,
LOCALE_LABELS,
Locale,
SUPPORTED_LOCALES,
translateText,
} from "@/shared/config/i18n";
import {
resolveInitialLocale,
saveManualLocale,
} from "@/features/i18n/model/resolveInitialLocale";
import { I18nProvider } from "@/features/i18n/model/useI18n";
export function I18nLayoutShell({
children,
}: {
children: React.ReactNode;
}) {
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
useEffect(() => {
const initialLocale = resolveInitialLocale();
setLocale(initialLocale);
document.documentElement.lang = initialLocale;
}, []);
const handleSetLocale = (nextLocale: Locale) => {
if (!SUPPORTED_LOCALES.includes(nextLocale)) return;
setLocale(nextLocale);
saveManualLocale(nextLocale);
document.documentElement.lang = nextLocale;
};
const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
const nextLocale = event.target.value as Locale;
handleSetLocale(nextLocale);
};
const t = (key: I18nKey) => translateText(locale, key);
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">
<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"
>
{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>
</header>
<main className="relative flex w-full flex-1 flex-col">{children}</main>
</div>
</I18nProvider>
);
}

297
src/shared/config/i18n.ts Normal file
View File

@@ -0,0 +1,297 @@
import { VoyageStatus } from "@/shared/types";
export const SUPPORTED_LOCALES = ["ko", "en", "ja"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const MANUAL_LOCALE_STORAGE_KEY = "focustella_locale_manual_v1";
export const LOCALE_LABELS: Record<Locale, string> = {
ko: "한국어",
en: "English",
ja: "日本語",
};
const enMessages = {
"layout.nav.log": "Logbook",
"layout.nav.settings": "Settings",
"layout.nav.language": "Language",
"common.loading": "Loading...",
"common.minuteShort": "min",
"routes.station.name": "Space Station",
"routes.station.tag": "Wait/Flexible",
"routes.station.description": "A safe zone you can stay in without time limits",
"routes.orion.name": "Orion",
"routes.orion.tag": "Deep Work",
"routes.orion.description": "60-minute focus voyage",
"routes.gemini.name": "Gemini",
"routes.gemini.tag": "Short Sprint",
"routes.gemini.description": "30-minute focus voyage",
"lobby.title": "Which constellation will you sail to?",
"lobby.subtitle": "Choose an orbit that helps your focus.",
"lobby.cta.station": "Enter Station (Wait)",
"lobby.cta.launch": "Launch Now",
"lobby.modal.boardingCheck": "Boarding Check",
"lobby.modal.routeBoarding": "{routeName} Route Boarding",
"lobby.modal.description": "Set your mission before starting this voyage.",
"boarding.check": "Boarding Check",
"boarding.routeBoarding": "{routeName} Route Boarding",
"boarding.missionLabel": "Core mission for this voyage",
"boarding.missionPlaceholder": "e.g. Finish 3 intro paragraphs",
"boarding.cancel": "Cancel",
"boarding.submit": "Dock Complete (Launch)",
"flight.badge.paused": "Paused",
"flight.badge.cruising": "Cruising",
"flight.missionLabel": "Voyage Mission",
"flight.pause": "Pause",
"flight.resume": "Resume",
"flight.finish.debrief": "Arrived (Debrief)",
"flight.finish.end": "End Voyage",
"flight.debrief.title": "Wrap up this voyage",
"flight.debrief.description": "Write a short note and save it to your logbook.",
"debrief.page.title": "You safely reached orbit",
"debrief.page.description": "Record this voyage briefly and wrap up.",
"debrief.status.label": "Voyage Result",
"debrief.option.completed.label": "Completed the mission",
"debrief.option.completed.desc": "I finished what I set out to do.",
"debrief.option.partial.label": "Made partial progress",
"debrief.option.partial.desc": "I advanced key parts and left next steps.",
"debrief.option.reoriented.label": "Redefined the mission",
"debrief.option.reoriented.desc": "I reset scope and priorities while working.",
"debrief.reflection.label": "Reflection after this voyage",
"debrief.reflection.placeholder":
"e.g. What worked, what did not, and what I learned",
"debrief.save": "Save to Logbook",
"log.title": "My Voyage Logs",
"log.empty": "No voyages recorded yet.",
"log.firstVoyage": "Start your first voyage",
"log.detail.back": "Back to list",
"log.detail.loadingOrNotFound": "Loading or Not Found...",
"log.detail.statusTitle": "Result Status",
"log.detail.progressTitle": "What I secured",
"log.detail.nextActionTitle": "Next Action",
"log.detail.initialNoteTitle": "Initial Note",
"settings.title": "Settings",
"settings.hideSeconds.title": "Hide Seconds",
"settings.hideSeconds.description":
"Show only minutes on the timer to reduce pressure.",
"status.completed": "Completed",
"status.partial": "Partial",
"status.reoriented": "Reoriented",
"status.aborted": "Aborted Early",
"status.in_progress": "In Progress",
} as const;
type I18nMessages = typeof enMessages;
const koMessages: I18nMessages = {
"layout.nav.log": "항해일지",
"layout.nav.settings": "설정",
"layout.nav.language": "언어",
"common.loading": "로딩 중...",
"common.minuteShort": "분",
"routes.station.name": "우주정거장",
"routes.station.tag": "대기/자유",
"routes.station.description": "시간 제한 없이 머무를 수 있는 안전지대",
"routes.orion.name": "오리온",
"routes.orion.tag": "딥워크",
"routes.orion.description": "60분 집중 항해",
"routes.gemini.name": "쌍둥이자리",
"routes.gemini.tag": "숏스프린트",
"routes.gemini.description": "30분 집중 항해",
"lobby.title": "어느 별자리로 출항할까요?",
"lobby.subtitle": "몰입하기 좋은 궤도입니다.",
"lobby.cta.station": "정거장 진입 (대기)",
"lobby.cta.launch": "바로 출항",
"lobby.modal.boardingCheck": "Boarding Check",
"lobby.modal.routeBoarding": "{routeName} 항로 탑승",
"lobby.modal.description": "항해를 시작하기 전에 목표를 설정하세요.",
"boarding.check": "Boarding Check",
"boarding.routeBoarding": "{routeName} 항로 탑승",
"boarding.missionLabel": "이번 항해의 핵심 목표",
"boarding.missionPlaceholder": "예: 서론 3문단 완성하기",
"boarding.cancel": "취소",
"boarding.submit": "도킹 완료 (출항)",
"flight.badge.paused": "일시정지",
"flight.badge.cruising": "순항 중",
"flight.missionLabel": "이번 항해 목표",
"flight.pause": "일시정지",
"flight.resume": "다시 시작",
"flight.finish.debrief": "도착 (회고)",
"flight.finish.end": "항해 종료",
"flight.debrief.title": "이번 항해를 정리하세요",
"flight.debrief.description": "짧게 기록하고 항해일지에 저장합니다.",
"debrief.page.title": "무사히 궤도에 도착했습니다",
"debrief.page.description": "이번 항해를 짧게 기록하고 마무리하세요.",
"debrief.status.label": "항해 결과",
"debrief.option.completed.label": "목표를 완수했어요",
"debrief.option.completed.desc": "시작할 때 정한 목표를 끝까지 해냈어요",
"debrief.option.partial.label": "일부까지 진행했어요",
"debrief.option.partial.desc": "중요한 부분을 진행했고 다음 단계가 남았어요",
"debrief.option.reoriented.label": "목표를 재정의했어요",
"debrief.option.reoriented.desc":
"진행 중 목표 범위와 우선순위를 다시 정했어요",
"debrief.reflection.label": "이번 항해를 마치고 느낀 점",
"debrief.reflection.placeholder":
"예: 잘한 점, 아쉬운 점, 느낀 점을 짧게 남겨보세요",
"debrief.save": "항해일지 저장",
"log.title": "나의 항해 기록",
"log.empty": "아직 기록된 항해가 없습니다.",
"log.firstVoyage": "첫 항해 떠나기",
"log.detail.back": "목록으로",
"log.detail.loadingOrNotFound": "로딩 중이거나 기록이 없습니다.",
"log.detail.statusTitle": "결과 상태",
"log.detail.progressTitle": "확보한 것",
"log.detail.nextActionTitle": "다음 행동",
"log.detail.initialNoteTitle": "초기 메모",
"settings.title": "설정",
"settings.hideSeconds.title": "초 단위 숨기기",
"settings.hideSeconds.description":
"타이머에서 분 단위만 표시하여 불안감을 줄입니다.",
"status.completed": "✅ 계획대로",
"status.partial": "🌓 부분 진행",
"status.reoriented": "🧭 방향 재설정",
"status.aborted": "🚨 조기 귀환",
"status.in_progress": "진행 중",
};
const jaMessages: I18nMessages = {
"layout.nav.log": "航海ログ",
"layout.nav.settings": "設定",
"layout.nav.language": "言語",
"common.loading": "読み込み中...",
"common.minuteShort": "分",
"routes.station.name": "宇宙ステーション",
"routes.station.tag": "待機/自由",
"routes.station.description": "時間制限なしで滞在できる安全地帯",
"routes.orion.name": "オリオン",
"routes.orion.tag": "ディープワーク",
"routes.orion.description": "60分集中航海",
"routes.gemini.name": "ふたご座",
"routes.gemini.tag": "ショートスプリント",
"routes.gemini.description": "30分集中航海",
"lobby.title": "どの星座へ航海しますか?",
"lobby.subtitle": "集中しやすい軌道を選びましょう。",
"lobby.cta.station": "ステーションへ入る(待機)",
"lobby.cta.launch": "今すぐ出航",
"lobby.modal.boardingCheck": "搭乗チェック",
"lobby.modal.routeBoarding": "{routeName} 航路に搭乗",
"lobby.modal.description": "航海を始める前に目標を設定してください。",
"boarding.check": "搭乗チェック",
"boarding.routeBoarding": "{routeName} 航路に搭乗",
"boarding.missionLabel": "今回の航海のコア目標",
"boarding.missionPlaceholder": "例: 導入の3段落を完成する",
"boarding.cancel": "キャンセル",
"boarding.submit": "ドッキング完了(出航)",
"flight.badge.paused": "一時停止",
"flight.badge.cruising": "巡航中",
"flight.missionLabel": "今回の航海目標",
"flight.pause": "一時停止",
"flight.resume": "再開",
"flight.finish.debrief": "到着(振り返り)",
"flight.finish.end": "航海終了",
"flight.debrief.title": "今回の航海を整理しましょう",
"flight.debrief.description": "短く記録して航海ログに保存します。",
"debrief.page.title": "無事に軌道へ到着しました",
"debrief.page.description": "今回の航海を短く記録して締めくくりましょう。",
"debrief.status.label": "航海結果",
"debrief.option.completed.label": "目標を達成しました",
"debrief.option.completed.desc": "開始時に決めた目標を最後まで完了しました。",
"debrief.option.partial.label": "一部まで進めました",
"debrief.option.partial.desc": "重要な部分を進め、次の段階が残っています。",
"debrief.option.reoriented.label": "目標を再定義しました",
"debrief.option.reoriented.desc":
"進行中に目標範囲と優先順位を見直しました。",
"debrief.reflection.label": "今回の航海を終えて感じたこと",
"debrief.reflection.placeholder":
"例: 良かった点・難しかった点・気づきを短く残してください",
"debrief.save": "航海ログに保存",
"log.title": "私の航海記録",
"log.empty": "まだ記録された航海がありません。",
"log.firstVoyage": "最初の航海を始める",
"log.detail.back": "一覧へ戻る",
"log.detail.loadingOrNotFound": "読み込み中、または記録が見つかりません。",
"log.detail.statusTitle": "結果ステータス",
"log.detail.progressTitle": "確保できたこと",
"log.detail.nextActionTitle": "次の行動",
"log.detail.initialNoteTitle": "初期メモ",
"settings.title": "設定",
"settings.hideSeconds.title": "秒表示を隠す",
"settings.hideSeconds.description":
"タイマーを分表示のみにしてプレッシャーを減らします。",
"status.completed": "✅ 計画どおり",
"status.partial": "🌓 一部進行",
"status.reoriented": "🧭 方針再設定",
"status.aborted": "🚨 早期帰還",
"status.in_progress": "進行中",
};
export const I18N_MESSAGES: Record<Locale, I18nMessages> = {
ko: koMessages,
en: enMessages,
ja: jaMessages,
};
export type I18nKey = keyof I18nMessages;
export type TranslationParams = Record<string, string | number>;
const interpolateMessage = (
template: string,
params?: TranslationParams,
): string => {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, paramKey) => {
const value = params[paramKey];
return value === undefined ? "" : String(value);
});
};
export const translateText = (
locale: Locale,
key: I18nKey | string,
params?: TranslationParams,
fallback = "",
): string => {
const localeMessages = I18N_MESSAGES[locale] as Record<string, string>;
const defaultMessages = I18N_MESSAGES[DEFAULT_LOCALE] as Record<string, string>;
const template =
localeMessages[key] ?? defaultMessages[key] ?? fallback;
return interpolateMessage(template, params);
};
type DebriefStatusOption = "completed" | "partial" | "reoriented";
export const DEBRIEF_STATUS_OPTIONS: Array<{
value: DebriefStatusOption;
labelKey: I18nKey;
descKey: I18nKey;
}> = [
{
value: "completed",
labelKey: "debrief.option.completed.label",
descKey: "debrief.option.completed.desc",
},
{
value: "partial",
labelKey: "debrief.option.partial.label",
descKey: "debrief.option.partial.desc",
},
{
value: "reoriented",
labelKey: "debrief.option.reoriented.label",
descKey: "debrief.option.reoriented.desc",
},
];
export const VOYAGE_STATUS_LABEL_KEYS: Record<VoyageStatus, I18nKey> = {
completed: "status.completed",
partial: "status.partial",
reoriented: "status.reoriented",
aborted: "status.aborted",
in_progress: "status.in_progress",
};

View File

@@ -3,23 +3,26 @@ import { Route } from '@/shared/types';
export const ROUTES: Route[] = [ export const ROUTES: Route[] = [
{ {
id: 'station', id: 'station',
name: '우주정거장',
durationMinutes: 0, durationMinutes: 0,
tag: '대기/자유', nameKey: 'routes.station.name',
description: '시간 제한 없이 머무를 수 있는 안전지대', tagKey: 'routes.station.tag',
descriptionKey: 'routes.station.description',
}, },
{ {
id: 'orion', id: 'orion',
name: '오리온',
durationMinutes: 60, durationMinutes: 60,
tag: '딥워크', nameKey: 'routes.orion.name',
description: '60분 집중 항해', tagKey: 'routes.orion.tag',
descriptionKey: 'routes.orion.description',
}, },
{ {
id: 'gemini', id: 'gemini',
name: '쌍둥이자리',
durationMinutes: 30, durationMinutes: 30,
tag: '숏스프린트', nameKey: 'routes.gemini.name',
description: '30분 집중 항해', tagKey: 'routes.gemini.tag',
descriptionKey: 'routes.gemini.description',
}, },
]; ];
export const findRouteById = (routeId: string) =>
ROUTES.find((route) => route.id === routeId);

View File

@@ -1,9 +1,9 @@
export interface Route { export interface Route {
id: string; id: string;
name: string;
durationMinutes: number; durationMinutes: number;
tag: string; nameKey: string;
description: string; tagKey: string;
descriptionKey: string;
} }
export type VoyageStatus = export type VoyageStatus =

View File

@@ -8,18 +8,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useI18n } from "@/features/i18n/model/useI18n";
import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n";
import { findRouteById } from "@/shared/config/routes";
import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store"; import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
import { Voyage, VoyageStatus } from "@/shared/types"; import { Voyage, VoyageStatus } from "@/shared/types";
const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [
{ value: "completed", label: "계획대로", desc: "목표를 달성했습니다" },
{ value: "partial", label: "부분 진행", desc: "절반의 성과를 만들었습니다" },
{
value: "reoriented",
label: "방향 재설정",
desc: "우선순위를 새로 정했습니다",
},
];
const FINISH_HOLD_MS = 1000; const FINISH_HOLD_MS = 1000;
type FlightHudWidgetProps = { type FlightHudWidgetProps = {
@@ -39,6 +33,7 @@ export function FlightHudWidget({
handlePauseToggle, handlePauseToggle,
handleFinish, handleFinish,
}: FlightHudWidgetProps) { }: FlightHudWidgetProps) {
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const [isDebriefOpen, setIsDebriefOpen] = useState(false); const [isDebriefOpen, setIsDebriefOpen] = useState(false);
const [finishedVoyage, setFinishedVoyage] = useState<Voyage | null>(null); const [finishedVoyage, setFinishedVoyage] = useState<Voyage | null>(null);
@@ -130,11 +125,21 @@ export function FlightHudWidget({
if (!voyage) return null; if (!voyage) return null;
const route = findRouteById(voyage.routeId);
const routeName = route
? t(route.nameKey, undefined, voyage.routeName)
: voyage.routeName;
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
value: option.value as VoyageStatus,
label: t(option.labelKey),
desc: t(option.descKey),
}));
return ( return (
<> <>
<div className="absolute top-8 z-10 text-center"> <div className="absolute top-8 z-10 text-center">
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur"> <span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
{voyage.routeName} · {isPaused ? "일시정지" : "순항 중"} {routeName} · {isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
</span> </span>
</div> </div>
@@ -147,7 +152,7 @@ export function FlightHudWidget({
<div className="relative z-10 mb-24 w-full max-w-2xl px-4"> <div className="relative z-10 mb-24 w-full max-w-2xl px-4">
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6"> <section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs"> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
{t("flight.missionLabel")}
</p> </p>
<p className="mt-2 max-h-32 overflow-y-auto break-words whitespace-pre-wrap pr-1 text-base leading-relaxed text-slate-100 md:text-lg"> <p className="mt-2 max-h-32 overflow-y-auto break-words whitespace-pre-wrap pr-1 text-base leading-relaxed text-slate-100 md:text-lg">
{voyage.missionText} {voyage.missionText}
@@ -160,7 +165,7 @@ export function FlightHudWidget({
onClick={handlePauseToggle} onClick={handlePauseToggle}
className="rounded-full border border-slate-600 bg-slate-900/50 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-300 backdrop-blur transition-all hover:border-slate-400 hover:bg-slate-800/80 hover:text-white" className="rounded-full border border-slate-600 bg-slate-900/50 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-300 backdrop-blur transition-all hover:border-slate-400 hover:bg-slate-800/80 hover:text-white"
> >
{isPaused ? "다시 시작" : "일시정지"} {isPaused ? t("flight.resume") : t("flight.pause")}
</button> </button>
<div className="relative"> <div className="relative">
<button <button
@@ -190,7 +195,9 @@ export function FlightHudWidget({
style={{ transform: `scaleX(${holdProgress})` }} style={{ transform: `scaleX(${holdProgress})` }}
/> />
<span className="relative z-10"> <span className="relative z-10">
{isCountdownCompleted ? "도착 (회고)" : "항해 종료"} {isCountdownCompleted
? t("flight.finish.debrief")
: t("flight.finish.end")}
</span> </span>
</button> </button>
</div> </div>
@@ -200,34 +207,36 @@ export function FlightHudWidget({
<DialogContent className="max-h-[90vh] overflow-y-auto border-slate-700/80 bg-slate-950 text-slate-100"> <DialogContent className="max-h-[90vh] overflow-y-auto border-slate-700/80 bg-slate-950 text-slate-100">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-white"> <DialogTitle className="text-white">
{t("flight.debrief.title")}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-slate-400">
. {t("flight.debrief.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleDebriefSubmit} className="space-y-6"> <form onSubmit={handleDebriefSubmit} className="space-y-6">
<section> <section>
<label className="mb-3 block text-sm font-medium text-slate-300"> <label className="mb-3 block text-sm font-medium text-slate-300">
{t("debrief.status.label")}
</label> </label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{statusOptions.map((opt) => ( {statusOptions.map((opt) => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
onClick={() => setStatus(opt.value)} onClick={() => setStatus(opt.value)}
className={`rounded-xl border p-4 text-left transition-all ${ className={`flex min-h-[116px] flex-col justify-between rounded-xl border px-4 py-3.5 text-left transition-all ${
status === opt.value status === opt.value
? "border-indigo-500 bg-indigo-900/40 ring-1 ring-indigo-500" ? "border-indigo-500 bg-indigo-900/40 ring-1 ring-indigo-500"
: "border-slate-800 bg-slate-900/50 hover:bg-slate-800" : "border-slate-800 bg-slate-900/50 hover:bg-slate-800"
}`} }`}
> >
<div className="mb-1 font-bold text-slate-200"> <div className="text-sm leading-snug font-bold text-slate-200 break-keep">
{opt.label} {opt.label}
</div> </div>
<div className="text-xs text-slate-500">{opt.desc}</div> <div className="mt-2 text-[11px] leading-relaxed text-slate-500">
{opt.desc}
</div>
</button> </button>
))} ))}
</div> </div>
@@ -235,13 +244,13 @@ export function FlightHudWidget({
<section> <section>
<label className="mb-2 block text-sm font-medium text-slate-300"> <label className="mb-2 block text-sm font-medium text-slate-300">
{t("debrief.reflection.label")}
</label> </label>
<input <input
type="text" type="text"
value={progress} value={progress}
onChange={(event) => setProgress(event.target.value)} onChange={(event) => setProgress(event.target.value)}
placeholder="예: 기획안 목차 구성 완료" placeholder={t("debrief.reflection.placeholder")}
className="w-full rounded-lg border border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-200 outline-none transition-all focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500" className="w-full rounded-lg border border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-200 outline-none transition-all focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/> />
</section> </section>
@@ -252,14 +261,14 @@ export function FlightHudWidget({
onClick={() => setIsDebriefOpen(false)} onClick={() => setIsDebriefOpen(false)}
className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 font-semibold text-slate-300 transition-colors hover:border-slate-500 hover:text-white" className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 font-semibold text-slate-300 transition-colors hover:border-slate-500 hover:text-white"
> >
{t("boarding.cancel")}
</button> </button>
<button <button
type="submit" type="submit"
disabled={!status} disabled={!status}
className="rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500" className="rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500"
> >
{t("debrief.save")}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,16 +1,17 @@
import { useState } from 'react'; import { useRouter } from "next/navigation";
import { useRouter } from 'next/navigation'; import { useState } from "react";
import { BoardingMissionForm, startVoyage } from '@/features/boarding';
import { useLobbyRedirect } from '@/features/lobby-session/model/useLobbyRedirect';
import { ROUTES } from '@/shared/config/routes';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from "@/components/ui/dialog";
import { BoardingMissionForm, startVoyage } from "@/features/boarding";
import { useI18n } from "@/features/i18n/model/useI18n";
import { useLobbyRedirect } from "@/features/lobby-session/model/useLobbyRedirect";
import { ROUTES } from "@/shared/config/routes";
function RouteCard({ function RouteCard({
route, route,
@@ -21,58 +22,62 @@ function RouteCard({
isCTA?: boolean; isCTA?: boolean;
onLaunch: (route: (typeof ROUTES)[number]) => void; onLaunch: (route: (typeof ROUTES)[number]) => void;
}) { }) {
const { t } = useI18n();
return ( return (
<div <div
className={`group relative flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/60 p-6 shadow-lg backdrop-blur-md transition-all duration-300 hover:border-indigo-500/50 hover:bg-slate-800/80 hover:shadow-indigo-900/20 ${isCTA ? 'min-h-[200px] items-center justify-center text-center' : ''}`} className={`group relative flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/60 p-6 shadow-lg backdrop-blur-md transition-all duration-300 hover:border-indigo-500/50 hover:bg-slate-800/80 hover:shadow-indigo-900/20 ${isCTA ? "min-h-[200px] items-center justify-center text-center" : ""}`}
> >
<div <div
className={`relative z-10 flex w-full ${isCTA ? 'flex-col items-center gap-4' : 'mb-4 items-start justify-between'}`} className={`relative z-10 flex w-full ${isCTA ? "flex-col items-center gap-4" : "mb-4 items-start justify-between"}`}
> >
<div className={isCTA ? 'flex flex-col items-center' : ''}> <div className={isCTA ? "flex flex-col items-center" : ""}>
<h3 <h3
className={`font-bold text-indigo-100 transition-colors group-hover:text-white ${isCTA ? 'text-3xl' : 'text-xl'}`} className={`font-bold text-indigo-100 transition-colors group-hover:text-white ${isCTA ? "text-3xl" : "text-xl"}`}
> >
{route.name} {t(route.nameKey, undefined, route.id)}
</h3> </h3>
<span className="mt-2 inline-block rounded-full border border-slate-800 bg-slate-950/50 px-2 py-0.5 text-xs font-medium text-slate-400"> <span className="mt-2 inline-block rounded-full border border-slate-800 bg-slate-950/50 px-2 py-0.5 text-xs font-medium text-slate-400">
{route.tag} {t(route.tagKey)}
</span> </span>
</div> </div>
{route.durationMinutes !== 0 && (
<span <span
className={`font-light text-slate-300 transition-colors group-hover:text-white ${isCTA ? 'mt-2 text-4xl' : 'text-3xl'}`} className={`font-light text-slate-300 transition-colors group-hover:text-white ${isCTA ? "mt-2 text-4xl" : "text-3xl"}`}
> >
{route.durationMinutes === 0 ? '∞' : route.durationMinutes} {route.durationMinutes !== 0 && route.durationMinutes}
<span className="ml-1 text-sm text-slate-500"> <span className="ml-1 text-sm text-slate-500">
{route.durationMinutes === 0 ? '' : 'min'} {route.durationMinutes !== 0 && t("common.minuteShort")}
</span>
</span> </span>
</span> )}
</div> </div>
{!isCTA && ( {!isCTA && (
<p className="relative z-10 mb-8 w-full flex-1 text-left text-sm leading-relaxed text-slate-400"> <p className="relative z-10 mb-8 w-full flex-1 text-left text-sm leading-relaxed text-slate-400">
{route.description} {t(route.descriptionKey)}
</p> </p>
)} )}
{isCTA && ( {isCTA && (
<p className="relative z-10 mb-6 max-w-lg text-base text-slate-400"> <p className="relative z-10 mt-2 mb-6 max-w-lg text-left text-base text-slate-400">
{route.description} {t(route.descriptionKey)}
</p> </p>
)} )}
<button <button
type="button" type="button"
onClick={() => onLaunch(route)} onClick={() => onLaunch(route)}
className={`relative z-10 flex items-center justify-center rounded-xl bg-indigo-600 font-bold text-white shadow-lg shadow-indigo-900/30 transition-all hover:bg-indigo-500 active:scale-[0.98] ${isCTA ? 'w-full max-w-md py-4 text-lg' : 'w-full py-4'}`} className={`relative z-10 flex items-center justify-center rounded-xl bg-indigo-600 font-bold text-white shadow-lg shadow-indigo-900/30 transition-all hover:bg-indigo-500 active:scale-[0.98] ${isCTA ? "w-full max-w-md py-4 text-lg" : "w-full py-4"}`}
> >
{isCTA ? '정거장 진입 (대기)' : '바로 출항'} {isCTA ? t("lobby.cta.station") : t("lobby.cta.launch")}
</button> </button>
</div> </div>
); );
} }
export function LobbyRoutesPanel() { export function LobbyRoutesPanel() {
const { t } = useI18n();
useLobbyRedirect(); useLobbyRedirect();
const router = useRouter(); const router = useRouter();
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null); const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
@@ -92,31 +97,40 @@ export function LobbyRoutesPanel() {
const started = startVoyage({ const started = startVoyage({
route: selectedRoute, route: selectedRoute,
mission, mission,
routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id),
}); });
if (!started) return; if (!started) return;
setIsBoardingOpen(false); setIsBoardingOpen(false);
router.push('/flight'); router.push("/flight");
}; };
return ( 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="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="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"> <h1 className="text-3xl font-bold tracking-tight text-slate-100 md:text-5xl">
? {t("lobby.title")}
</h1> </h1>
<p className="text-lg text-slate-300"> .</p> <p className="text-lg text-slate-300">{t("lobby.subtitle")}</p>
</div> </div>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<div className="w-full"> <div className="w-full">
<RouteCard route={stationRoute} isCTA={true} onLaunch={handleOpenBoarding} /> <RouteCard
route={stationRoute}
isCTA={true}
onLaunch={handleOpenBoarding}
/>
</div> </div>
<div className="grid w-full grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
{normalRoutes.map((route) => ( {normalRoutes.map((route) => (
<RouteCard key={route.id} route={route} onLaunch={handleOpenBoarding} /> <RouteCard
key={route.id}
route={route}
onLaunch={handleOpenBoarding}
/>
))} ))}
</div> </div>
</div> </div>
@@ -128,13 +142,15 @@ export function LobbyRoutesPanel() {
> >
<DialogHeader className="mb-2"> <DialogHeader className="mb-2">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-widest text-indigo-400"> <h2 className="mb-1 text-sm font-semibold uppercase tracking-widest text-indigo-400">
Boarding Check {t("lobby.modal.boardingCheck")}
</h2> </h2>
<DialogTitle className="text-2xl font-bold text-white"> <DialogTitle className="text-2xl font-bold text-white">
{selectedRoute.name} {t("lobby.modal.routeBoarding", {
routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id),
})}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-slate-400">
. {t("lobby.modal.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>