feat: 다국어 화
This commit is contained in:
@@ -71,6 +71,18 @@
|
||||
- `/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를 소유한다
|
||||
|
||||
## 변경 정책
|
||||
|
||||
- 구조 리팩토링은 명시적으로 요청되지 않는 한 동작을 바꾸면 안 된다
|
||||
|
||||
@@ -4,15 +4,18 @@ import { Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ROUTES } from '@/shared/config/routes';
|
||||
import { BoardingMissionForm, startVoyage } from '@/features/boarding';
|
||||
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||
|
||||
function BoardingContent() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const routeId = searchParams.get('routeId');
|
||||
const route = ROUTES.find(r => r.id === routeId) || ROUTES[0];
|
||||
const routeName = t(route.nameKey, undefined, route.id);
|
||||
|
||||
const handleDocking = (mission: string) => {
|
||||
const started = startVoyage({ route, mission });
|
||||
const started = startVoyage({ route, mission, routeName });
|
||||
if (!started) return;
|
||||
router.push('/flight');
|
||||
};
|
||||
@@ -20,8 +23,12 @@ function BoardingContent() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm text-indigo-400 font-semibold mb-1 uppercase tracking-widest">Boarding Check</h2>
|
||||
<h1 className="text-3xl font-bold text-white">{route.name} 항로 탑승</h1>
|
||||
<h2 className="text-sm text-indigo-400 font-semibold mb-1 uppercase tracking-widest">
|
||||
{t('boarding.check')}
|
||||
</h2>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{t('boarding.routeBoarding', { routeName })}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 flex-1">
|
||||
@@ -35,8 +42,10 @@ function BoardingContent() {
|
||||
}
|
||||
|
||||
export default function BoardingPage() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
||||
<Suspense fallback={<div className="p-6">{t('common.loading')}</div>}>
|
||||
<BoardingContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
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 { Voyage, VoyageStatus } from '@/shared/types';
|
||||
|
||||
export default function DebriefPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [voyage, setVoyage] = useState<Voyage | null>(null);
|
||||
|
||||
@@ -43,54 +46,58 @@ export default function DebriefPage() {
|
||||
|
||||
if (!voyage) return null;
|
||||
|
||||
const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [
|
||||
{ value: 'completed', label: '계획대로', desc: '목표를 달성했습니다' },
|
||||
{ value: 'partial', label: '부분 진행', desc: '절반의 성공입니다' },
|
||||
{ value: 'reoriented', label: '방향 재설정', desc: '새로운 발견을 했습니다' },
|
||||
];
|
||||
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
|
||||
value: option.value as VoyageStatus,
|
||||
label: t(option.labelKey),
|
||||
desc: t(option.descKey),
|
||||
}));
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">무사히 궤도에 도착했습니다</h1>
|
||||
<p className="text-slate-400">이번 항해를 짧게 기록하고 마무리하세요.</p>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">{t('debrief.page.title')}</h1>
|
||||
<p className="text-slate-400">{t('debrief.page.description')}</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8 flex-1">
|
||||
{/* Question 1: Status */}
|
||||
<section>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-3">
|
||||
항해 결과
|
||||
{t('debrief.status.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) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
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
|
||||
? 'bg-indigo-900/40 border-indigo-500 ring-1 ring-indigo-500'
|
||||
: '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-xs text-slate-500">{opt.desc}</div>
|
||||
<div className="text-sm leading-snug font-bold text-slate-200 break-keep">
|
||||
{opt.label}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-relaxed text-slate-500">
|
||||
{opt.desc}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Question 2: Secured */}
|
||||
{/* Question 2: Reflection */}
|
||||
<section>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
이번 항해에서 확보한 것 (What)
|
||||
{t('debrief.reflection.label')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={progress}
|
||||
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"
|
||||
/>
|
||||
</section>
|
||||
@@ -100,7 +107,7 @@ export default function DebriefPage() {
|
||||
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"
|
||||
>
|
||||
항해일지 저장
|
||||
{t('debrief.save')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Link from "next/link";
|
||||
import { I18nLayoutShell } from "@/features/i18n/ui/I18nLayoutShell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Focustella",
|
||||
@@ -13,23 +13,9 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
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">
|
||||
{/* Layout Container */}
|
||||
<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>
|
||||
<I18nLayoutShell>{children}</I18nLayoutShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 { Voyage } from '@/shared/types';
|
||||
|
||||
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { t } = useI18n();
|
||||
// Next.js 15: params is a Promise
|
||||
const resolvedParams = use(params);
|
||||
const router = useRouter();
|
||||
const [log, setLog] = useState<Voyage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -18,47 +20,49 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
|
||||
if (found) setLog(found);
|
||||
}, [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 (
|
||||
<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">← 목록으로</Link>
|
||||
<Link href="/log" className="text-sm text-indigo-400 hover:text-indigo-300 mb-4 inline-block">
|
||||
← {t('log.detail.back')}
|
||||
</Link>
|
||||
<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>
|
||||
<span>•</span>
|
||||
<span>{log.routeName} ({log.durationMinutes}m)</span>
|
||||
<span>{routeName} ({log.durationMinutes}{t('common.minuteShort')})</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
<p className="text-lg text-indigo-100">
|
||||
{log.status === 'completed' && '✅ 계획대로'}
|
||||
{log.status === 'partial' && '🌓 부분 진행'}
|
||||
{log.status === 'reoriented' && '🧭 방향 재설정'}
|
||||
{log.status === 'aborted' && '🚨 조기 귀환'}
|
||||
{log.status === 'in_progress' && '진행 중'}
|
||||
</p>
|
||||
<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">{statusLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.notes && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { Voyage, VoyageStatus } from '@/shared/types';
|
||||
|
||||
export default function LogListPage() {
|
||||
const { t } = useI18n();
|
||||
const [logs, setLogs] = useState<Voyage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -13,29 +17,29 @@ export default function LogListPage() {
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = (s: VoyageStatus) => {
|
||||
switch(s) {
|
||||
case 'completed': return '✅ 계획대로';
|
||||
case 'partial': return '🌓 부분 진행';
|
||||
case 'reoriented': return '🧭 방향 재설정';
|
||||
case 'aborted': return '🚨 조기 귀환';
|
||||
default: return '진행 중';
|
||||
}
|
||||
return t(VOYAGE_STATUS_LABEL_KEYS[s], undefined, t('status.in_progress'));
|
||||
};
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<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">
|
||||
첫 항해 떠나기
|
||||
{t('log.firstVoyage')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{logs.map((log) => (
|
||||
{logs.map((log) => {
|
||||
const route = findRouteById(log.routeId);
|
||||
const routeName = route
|
||||
? t(route.nameKey, undefined, log.routeName)
|
||||
: log.routeName;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={log.id}
|
||||
href={`/log/${log.id}`}
|
||||
@@ -52,9 +56,13 @@ export default function LogListPage() {
|
||||
<h3 className="font-semibold text-slate-200 truncate mb-1">
|
||||
{log.missionText}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">{log.routeName} · {log.durationMinutes}min</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{routeName} · {log.durationMinutes}
|
||||
{t('common.minuteShort')}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||
import { getPreferences, savePreferences } from '@/shared/lib/store';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const [hideSeconds, setHideSeconds] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,13 +21,13 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<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="flex items-center justify-between bg-slate-900/40 p-4 rounded-xl border border-slate-800">
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-200">초 단위 숨기기</h3>
|
||||
<p className="text-xs text-slate-500">타이머에서 분 단위만 표시하여 불안감을 줄입니다.</p>
|
||||
<h3 className="font-medium text-slate-200">{t('settings.hideSeconds.title')}</h3>
|
||||
<p className="text-xs text-slate-500">{t('settings.hideSeconds.description')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -9,9 +9,11 @@ const createVoyageId = () =>
|
||||
export const startVoyage = ({
|
||||
route,
|
||||
mission,
|
||||
routeName,
|
||||
}: {
|
||||
route: Route;
|
||||
mission: string;
|
||||
routeName: string;
|
||||
}) => {
|
||||
const missionText = mission.trim();
|
||||
if (!missionText) {
|
||||
@@ -21,7 +23,7 @@ export const startVoyage = ({
|
||||
const newVoyage: Voyage = {
|
||||
id: createVoyageId(),
|
||||
routeId: route.id,
|
||||
routeName: route.name,
|
||||
routeName,
|
||||
durationMinutes: route.durationMinutes,
|
||||
startedAt: Date.now(),
|
||||
status: 'in_progress',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||
|
||||
export function BoardingMissionForm({
|
||||
onDock,
|
||||
@@ -13,6 +14,7 @@ export function BoardingMissionForm({
|
||||
autoFocus?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [mission, setMission] = useState('');
|
||||
const trimmedMission = mission.trim();
|
||||
const canSubmit = Boolean(trimmedMission);
|
||||
@@ -30,13 +32,13 @@ export function BoardingMissionForm({
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-300">
|
||||
이번 항해의 핵심 목표
|
||||
{t('boarding.missionLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mission}
|
||||
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"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
@@ -49,7 +51,7 @@ export function BoardingMissionForm({
|
||||
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"
|
||||
>
|
||||
취소
|
||||
{t('boarding.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -57,7 +59,7 @@ export function BoardingMissionForm({
|
||||
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'}`}
|
||||
>
|
||||
도킹 완료 (출항)
|
||||
{t('boarding.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
42
src/features/i18n/model/resolveInitialLocale.ts
Normal file
42
src/features/i18n/model/resolveInitialLocale.ts
Normal 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);
|
||||
};
|
||||
45
src/features/i18n/model/useI18n.tsx
Normal file
45
src/features/i18n/model/useI18n.tsx
Normal 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);
|
||||
87
src/features/i18n/ui/I18nLayoutShell.tsx
Normal file
87
src/features/i18n/ui/I18nLayoutShell.tsx
Normal 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
297
src/shared/config/i18n.ts
Normal 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",
|
||||
};
|
||||
@@ -3,23 +3,26 @@ import { Route } from '@/shared/types';
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
id: 'station',
|
||||
name: '우주정거장',
|
||||
durationMinutes: 0,
|
||||
tag: '대기/자유',
|
||||
description: '시간 제한 없이 머무를 수 있는 안전지대',
|
||||
nameKey: 'routes.station.name',
|
||||
tagKey: 'routes.station.tag',
|
||||
descriptionKey: 'routes.station.description',
|
||||
},
|
||||
{
|
||||
id: 'orion',
|
||||
name: '오리온',
|
||||
durationMinutes: 60,
|
||||
tag: '딥워크',
|
||||
description: '60분 집중 항해',
|
||||
nameKey: 'routes.orion.name',
|
||||
tagKey: 'routes.orion.tag',
|
||||
descriptionKey: 'routes.orion.description',
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: '쌍둥이자리',
|
||||
durationMinutes: 30,
|
||||
tag: '숏스프린트',
|
||||
description: '30분 집중 항해',
|
||||
nameKey: 'routes.gemini.name',
|
||||
tagKey: 'routes.gemini.tag',
|
||||
descriptionKey: 'routes.gemini.description',
|
||||
},
|
||||
];
|
||||
|
||||
export const findRouteById = (routeId: string) =>
|
||||
ROUTES.find((route) => route.id === routeId);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export interface Route {
|
||||
id: string;
|
||||
name: string;
|
||||
durationMinutes: number;
|
||||
tag: string;
|
||||
description: string;
|
||||
nameKey: string;
|
||||
tagKey: string;
|
||||
descriptionKey: string;
|
||||
}
|
||||
|
||||
export type VoyageStatus =
|
||||
|
||||
@@ -8,18 +8,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { 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;
|
||||
|
||||
type FlightHudWidgetProps = {
|
||||
@@ -39,6 +33,7 @@ export function FlightHudWidget({
|
||||
handlePauseToggle,
|
||||
handleFinish,
|
||||
}: FlightHudWidgetProps) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [isDebriefOpen, setIsDebriefOpen] = useState(false);
|
||||
const [finishedVoyage, setFinishedVoyage] = useState<Voyage | null>(null);
|
||||
@@ -130,11 +125,21 @@ export function FlightHudWidget({
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
{voyage.routeName} · {isPaused ? "일시정지" : "순항 중"}
|
||||
{routeName} · {isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +152,7 @@ export function FlightHudWidget({
|
||||
<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">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
|
||||
이번 항해 목표
|
||||
{t("flight.missionLabel")}
|
||||
</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">
|
||||
{voyage.missionText}
|
||||
@@ -160,7 +165,7 @@ export function FlightHudWidget({
|
||||
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"
|
||||
>
|
||||
{isPaused ? "다시 시작" : "일시정지"}
|
||||
{isPaused ? t("flight.resume") : t("flight.pause")}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -190,7 +195,9 @@ export function FlightHudWidget({
|
||||
style={{ transform: `scaleX(${holdProgress})` }}
|
||||
/>
|
||||
<span className="relative z-10">
|
||||
{isCountdownCompleted ? "도착 (회고)" : "항해 종료"}
|
||||
{isCountdownCompleted
|
||||
? t("flight.finish.debrief")
|
||||
: t("flight.finish.end")}
|
||||
</span>
|
||||
</button>
|
||||
</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">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
이번 항해를 정리하세요
|
||||
{t("flight.debrief.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
짧게 기록하고 항해일지에 저장합니다.
|
||||
{t("flight.debrief.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleDebriefSubmit} className="space-y-6">
|
||||
<section>
|
||||
<label className="mb-3 block text-sm font-medium text-slate-300">
|
||||
항해 결과
|
||||
{t("debrief.status.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) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
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
|
||||
? "border-indigo-500 bg-indigo-900/40 ring-1 ring-indigo-500"
|
||||
: "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}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
@@ -235,13 +244,13 @@ export function FlightHudWidget({
|
||||
|
||||
<section>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-300">
|
||||
이번 항해에서 확보한 것
|
||||
{t("debrief.reflection.label")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={progress}
|
||||
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"
|
||||
/>
|
||||
</section>
|
||||
@@ -252,14 +261,14 @@ export function FlightHudWidget({
|
||||
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"
|
||||
>
|
||||
취소
|
||||
{t("boarding.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
항해일지 저장
|
||||
{t("debrief.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
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({
|
||||
route,
|
||||
@@ -21,58 +22,62 @@ function RouteCard({
|
||||
isCTA?: boolean;
|
||||
onLaunch: (route: (typeof ROUTES)[number]) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<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
|
||||
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
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{route.durationMinutes !== 0 && (
|
||||
<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">
|
||||
{route.durationMinutes === 0 ? '' : 'min'}
|
||||
{route.durationMinutes !== 0 && t("common.minuteShort")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCTA && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{isCTA && (
|
||||
<p className="relative z-10 mb-6 max-w-lg text-base text-slate-400">
|
||||
{route.description}
|
||||
<p className="relative z-10 mt-2 mb-6 max-w-lg text-left text-base text-slate-400">
|
||||
{t(route.descriptionKey)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LobbyRoutesPanel() {
|
||||
const { t } = useI18n();
|
||||
useLobbyRedirect();
|
||||
const router = useRouter();
|
||||
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
||||
@@ -92,31 +97,40 @@ export function LobbyRoutesPanel() {
|
||||
const started = startVoyage({
|
||||
route: selectedRoute,
|
||||
mission,
|
||||
routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id),
|
||||
});
|
||||
|
||||
if (!started) return;
|
||||
|
||||
setIsBoardingOpen(false);
|
||||
router.push('/flight');
|
||||
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">몰입하기 좋은 궤도입니다.</p>
|
||||
<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="w-full">
|
||||
<RouteCard route={stationRoute} isCTA={true} onLaunch={handleOpenBoarding} />
|
||||
<RouteCard
|
||||
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} />
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
route={route}
|
||||
onLaunch={handleOpenBoarding}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,13 +142,15 @@ export function LobbyRoutesPanel() {
|
||||
>
|
||||
<DialogHeader className="mb-2">
|
||||
<h2 className="mb-1 text-sm font-semibold uppercase tracking-widest text-indigo-400">
|
||||
Boarding Check
|
||||
{t("lobby.modal.boardingCheck")}
|
||||
</h2>
|
||||
<DialogTitle className="text-2xl font-bold text-white">
|
||||
{selectedRoute.name} 항로 탑승
|
||||
{t("lobby.modal.routeBoarding", {
|
||||
routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id),
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
항해를 시작하기 전에 목표를 설정하세요.
|
||||
{t("lobby.modal.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user