refactor(i18n): 사용자 문구 참조를 중앙화
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { AuthResponse } from '@/features/auth/types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
@@ -57,14 +58,14 @@ const parseErrorMessage = async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
return `요청 실패: ${response.status}`;
|
||||
return copy.common.requestFailed(response.status);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await response.json()) as ApiErrorPayload;
|
||||
return payload.message ?? payload.error ?? `요청 실패: ${response.status}`;
|
||||
return payload.message ?? payload.error ?? copy.common.requestFailed(response.status);
|
||||
} catch {
|
||||
return `요청 실패: ${response.status}`;
|
||||
return copy.common.requestFailed(response.status);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useSocialLogin } from "../hooks/useSocialLogin";
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
@@ -46,7 +47,7 @@ const SocialLoginButtons = () => {
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
{isLoading ? "연결 중..." : "Google로 계속하기"}
|
||||
{isLoading ? copy.auth.social.connecting : copy.auth.social.continueWithGoogle}
|
||||
</button>
|
||||
|
||||
{/* 2. Apple 로그인 (react-apple-signin-auth 연동) */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useGoogleLogin } from '@react-oauth/google';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import appleAuthHelpers from 'react-apple-signin-auth';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
import { authApi } from '../api/authApi';
|
||||
|
||||
@@ -58,7 +59,7 @@ export const useSocialLogin = () => {
|
||||
router.push('/app');
|
||||
} catch (err) {
|
||||
console.error(`[${provider}] 로그인 실패:`, err);
|
||||
setError('로그인에 실패했습니다. 다시 시도해 주세요.');
|
||||
setError(copy.auth.errors.loginFailed);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -73,7 +74,7 @@ export const useSocialLogin = () => {
|
||||
handleSocialLogin('google', tokenResponse.access_token);
|
||||
},
|
||||
onError: () => {
|
||||
setError('구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.');
|
||||
setError(copy.auth.errors.googleFailed);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,12 +100,12 @@ export const useSocialLogin = () => {
|
||||
},
|
||||
onError: (err: AppleSignInError) => {
|
||||
console.error('Apple SignIn error:', err);
|
||||
setError('애플 로그인 중 오류가 발생했습니다.');
|
||||
setError(copy.auth.errors.appleFailed);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('애플 로그인 초기화 실패');
|
||||
setError(copy.auth.errors.appleInitFailed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,7 +117,7 @@ export const useSocialLogin = () => {
|
||||
if (response?.accessToken) {
|
||||
handleSocialLogin('facebook', response.accessToken);
|
||||
} else {
|
||||
setError('페이스북 로그인에 실패했습니다.');
|
||||
setError(copy.auth.errors.facebookFailed);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useHoldToConfirm } from '../model/useHoldToConfirm';
|
||||
|
||||
@@ -39,7 +40,7 @@ export const ExitHoldButton = ({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="길게 눌러 나가기"
|
||||
aria-label={copy.space.exitHold.holdToExitAriaLabel}
|
||||
onMouseDown={start}
|
||||
onMouseUp={cancel}
|
||||
onMouseLeave={cancel}
|
||||
@@ -90,7 +91,7 @@ export const ExitHoldButton = ({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="길게 눌러 나가기"
|
||||
aria-label={copy.space.exitHold.holdToExitAriaLabel}
|
||||
onMouseDown={start}
|
||||
onMouseUp={cancel}
|
||||
onMouseLeave={cancel}
|
||||
@@ -117,7 +118,7 @@ export const ExitHoldButton = ({
|
||||
) : null}
|
||||
<span className="relative z-10 inline-flex items-center gap-1">
|
||||
<span aria-hidden>⤫</span>
|
||||
<span>나가기</span>
|
||||
<span>{copy.space.exitHold.exit}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import {
|
||||
focusSessionApi,
|
||||
type CompleteFocusSessionRequest,
|
||||
@@ -96,7 +97,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
return applySession(session);
|
||||
} catch (nextError) {
|
||||
const message =
|
||||
nextError instanceof Error ? nextError.message : '세션 엔진과 동기화하지 못했어요.';
|
||||
nextError instanceof Error ? nextError.message : copy.focusSession.syncFailed;
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
@@ -188,7 +189,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
startSession: async (payload) => {
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.startSession(payload),
|
||||
'세션을 시작하지 못했어요.',
|
||||
copy.focusSession.startFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
@@ -200,7 +201,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.pauseSession(),
|
||||
'세션을 일시정지하지 못했어요.',
|
||||
copy.focusSession.pauseFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
@@ -212,7 +213,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.resumeSession(),
|
||||
'세션을 다시 시작하지 못했어요.',
|
||||
copy.focusSession.resumeFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
@@ -224,7 +225,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.restartCurrentPhase(),
|
||||
'현재 페이즈를 다시 시작하지 못했어요.',
|
||||
copy.focusSession.restartPhaseFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
@@ -236,7 +237,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.completeSession(payload),
|
||||
'세션을 완료 처리하지 못했어요.',
|
||||
copy.focusSession.completeFailed,
|
||||
);
|
||||
|
||||
if (session) {
|
||||
@@ -252,7 +253,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
|
||||
const result = await runMutation(
|
||||
() => focusSessionApi.abandonSession(),
|
||||
'세션을 종료하지 못했어요.',
|
||||
copy.focusSession.abandonFailed,
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RecentThought } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface InboxListProps {
|
||||
@@ -17,7 +18,7 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
|
||||
className,
|
||||
)}
|
||||
>
|
||||
지금은 비어 있어요. 집중 중 떠오른 생각을 여기로 주차할 수 있어요.
|
||||
{copy.space.inbox.empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -51,14 +52,14 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
|
||||
: 'border-white/20 bg-white/8 text-white/76 hover:bg-white/14',
|
||||
)}
|
||||
>
|
||||
{thought.isCompleted ? '완료됨' : '완료'}
|
||||
{thought.isCompleted ? copy.space.inbox.completed : copy.space.inbox.complete}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteThought(thought)}
|
||||
className="inline-flex h-6 items-center rounded-full border border-rose-200/30 bg-rose-200/10 px-2 text-[10px] text-rose-100/88 transition-colors hover:bg-rose-200/18"
|
||||
>
|
||||
삭제
|
||||
{copy.space.inbox.delete}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface ManagePlanSheetContentProps {
|
||||
onClose: () => void;
|
||||
onManage: () => void;
|
||||
@@ -12,8 +14,8 @@ export const ManagePlanSheetContent = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">PRO 관리</h3>
|
||||
<p className="text-xs text-white/62">결제/복원은 더미 동작이며 실제 연동은 하지 않아요.</p>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">{copy.space.paywall.manageTitle}</h3>
|
||||
<p className="text-xs text-white/62">{copy.space.paywall.manageDescription}</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -22,14 +24,14 @@ export const ManagePlanSheetContent = ({
|
||||
onClick={onManage}
|
||||
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
구독 관리 열기
|
||||
{copy.space.paywall.openSubscription}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
구매 복원
|
||||
{copy.space.paywall.restorePurchase}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +41,7 @@ export const ManagePlanSheetContent = ({
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||
>
|
||||
닫기
|
||||
{copy.common.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface PaywallSheetContentProps {
|
||||
onStartPro: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const VALUE_POINTS = [
|
||||
'프리미엄 Scene Packs',
|
||||
'확장 Sound Packs',
|
||||
'프로필 저장 / 불러오기',
|
||||
];
|
||||
const VALUE_POINTS = copy.space.paywall.points;
|
||||
|
||||
export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">PRO에서 더 많은 공간과 사운드를 열어둘 수 있어요.</h3>
|
||||
<p className="text-xs text-white/62">잠금 항목을 누른 순간에만 열리는 더미 결제 시트입니다.</p>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">{copy.space.paywall.title}</h3>
|
||||
<p className="text-xs text-white/62">{copy.space.paywall.description}</p>
|
||||
</header>
|
||||
|
||||
<ul className="space-y-2">
|
||||
@@ -33,14 +31,14 @@ export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContent
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||
>
|
||||
나중에
|
||||
{copy.space.paywall.later}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartPro}
|
||||
className="rounded-full border border-sky-200/44 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300"
|
||||
>
|
||||
PRO 시작하기
|
||||
{copy.space.paywall.startPro}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface PlanPillProps {
|
||||
@@ -20,7 +21,7 @@ export const PlanPill = ({ plan, onClick }: PlanPillProps) => {
|
||||
: 'border-white/20 bg-white/8 text-white/82 hover:bg-white/14',
|
||||
)}
|
||||
>
|
||||
{isPro ? 'PRO' : 'Normal'}
|
||||
{isPro ? copy.space.toolsDock.planPro : copy.space.toolsDock.planNormal}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
DEFAULT_PRESET_OPTIONS,
|
||||
NOTIFICATION_INTENSITY_OPTIONS,
|
||||
} from '@/shared/config/settingsOptions';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
|
||||
export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number];
|
||||
@@ -17,7 +18,7 @@ export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
|
||||
|
||||
export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
|
||||
reduceMotion: false,
|
||||
notificationIntensity: '기본',
|
||||
notificationIntensity: copy.preferences.defaultNotificationIntensity,
|
||||
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import {
|
||||
DEFAULT_USER_FOCUS_PREFERENCES,
|
||||
preferencesApi,
|
||||
@@ -57,7 +58,7 @@ export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => {
|
||||
setError(null);
|
||||
} catch (nextError) {
|
||||
const message =
|
||||
nextError instanceof Error ? nextError.message : '설정을 불러오지 못했어요.';
|
||||
nextError instanceof Error ? nextError.message : copy.preferences.loadFailed;
|
||||
setPreferences(DEFAULT_USER_FOCUS_PREFERENCES);
|
||||
setError(message);
|
||||
} finally {
|
||||
@@ -80,13 +81,13 @@ export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => {
|
||||
try {
|
||||
const persisted = await preferencesApi.updateFocusPreferences(patch);
|
||||
setPreferences(persisted);
|
||||
pushSavedLabel('저장됨');
|
||||
pushSavedLabel(copy.preferences.saved);
|
||||
} catch (nextError) {
|
||||
const message =
|
||||
nextError instanceof Error ? nextError.message : '설정을 저장하지 못했어요.';
|
||||
nextError instanceof Error ? nextError.message : copy.preferences.saveFailed;
|
||||
setPreferences(previous);
|
||||
setError(message);
|
||||
pushSavedLabel('저장 실패');
|
||||
pushSavedLabel(copy.preferences.saveFailedLabel);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const RECOVERY_30S_BUTTON_LABEL = '숨 고르기 30초';
|
||||
export const RECOVERY_30S_MODE_LABEL = 'BREATHE';
|
||||
export const RECOVERY_30S_TOAST_MESSAGE = '잠깐 숨 고르고, 다시 천천히 시작해요.';
|
||||
export const RECOVERY_30S_COMPLETE_MESSAGE = '준비됐어요. 집중으로 돌아가요.';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const RECOVERY_30S_BUTTON_LABEL = copy.restart30s.button;
|
||||
export const RECOVERY_30S_MODE_LABEL = copy.restart30s.mode;
|
||||
export const RECOVERY_30S_TOAST_MESSAGE = copy.restart30s.toast;
|
||||
export const RECOVERY_30S_COMPLETE_MESSAGE = copy.restart30s.complete;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface SceneSelectCarouselProps {
|
||||
@@ -33,7 +34,7 @@ export const SceneSelectCarousel = ({
|
||||
: 'border-white/16 hover:border-white/24',
|
||||
)}
|
||||
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||
aria-label={`${scene.name} 선택`}
|
||||
aria-label={`${scene.name} ${copy.common.select}`}
|
||||
>
|
||||
<span className="absolute inset-x-0 bottom-0 h-[56%] bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.2)_52%,rgba(2,6,23,0.24)_100%)]" />
|
||||
{selected ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { GoalChip } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
@@ -21,6 +22,7 @@ export const SessionGoalField = ({
|
||||
onGoalChange,
|
||||
onGoalChipSelect,
|
||||
}: SessionGoalFieldProps) => {
|
||||
const { sessionGoal } = copy.space;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,7 +43,7 @@ export const SessionGoalField = ({
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="space-goal-input" className="text-[12px] font-medium text-white/88">
|
||||
이번 25분, 딱 한 가지 <span className="text-sky-100">(필수)</span>
|
||||
{sessionGoal.label} <span className="text-sky-100">{sessionGoal.required}</span>
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -49,10 +51,10 @@ export const SessionGoalField = ({
|
||||
autoFocus={autoFocus}
|
||||
value={goalInput}
|
||||
onChange={(event) => onGoalChange(event.target.value)}
|
||||
placeholder="예: 계약서 1페이지 정리"
|
||||
placeholder={sessionGoal.placeholder}
|
||||
className="h-10 w-full rounded-xl border border-white/14 bg-slate-950/42 px-3 text-sm text-white placeholder:text-white/42 focus:border-sky-200/46 focus:outline-none"
|
||||
/>
|
||||
<p className="text-[11px] text-white/54">크게 말고, 바로 다음 한 조각.</p>
|
||||
<p className="text-[11px] text-white/54">{sessionGoal.hint}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SoundAssetManifestItem } from '@/entities/media';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface UseSoundPlaybackOptions {
|
||||
selectedPresetId: string;
|
||||
@@ -66,7 +67,7 @@ export const useSoundPlayback = ({
|
||||
const handleError = () => {
|
||||
setReady(false);
|
||||
setPlaying(false);
|
||||
setError('사운드 파일을 불러오지 못했어요.');
|
||||
setError(copy.soundPlayback.loadFailed);
|
||||
};
|
||||
|
||||
audio.addEventListener('loadstart', handleLoadStart);
|
||||
@@ -146,7 +147,7 @@ export const useSoundPlayback = ({
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPlaying(false);
|
||||
setError('브라우저가 사운드 재생을 보류했어요.');
|
||||
setError(copy.soundPlayback.browserDeferred);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { Toggle } from '@/shared/ui';
|
||||
import type { SoundTrackKey } from '../model/useSoundPresetSelection';
|
||||
|
||||
@@ -16,13 +17,7 @@ interface SoundPresetControlsProps {
|
||||
onTrackLevelChange: (track: SoundTrackKey, level: number) => void;
|
||||
}
|
||||
|
||||
const TRACK_LABELS: Record<SoundTrackKey, string> = {
|
||||
white: 'White',
|
||||
rain: 'Rain',
|
||||
cafe: 'Cafe',
|
||||
wave: 'Wave',
|
||||
fan: 'Fan',
|
||||
};
|
||||
const TRACK_LABELS: Record<SoundTrackKey, string> = copy.space.soundPresetControls.trackLabels;
|
||||
|
||||
const clampSliderValue = (value: number) => Math.max(0, Math.min(100, value));
|
||||
|
||||
@@ -39,10 +34,11 @@ export const SoundPresetControls = ({
|
||||
trackLevels,
|
||||
onTrackLevelChange,
|
||||
}: SoundPresetControlsProps) => {
|
||||
const { soundPresetControls } = copy.space;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.11em] text-white/58">Preset</p>
|
||||
<p className="text-xs uppercase tracking-[0.11em] text-white/58">{soundPresetControls.preset}</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SOUND_PRESETS.map((preset) => (
|
||||
<button
|
||||
@@ -66,16 +62,16 @@ export const SoundPresetControls = ({
|
||||
onClick={onToggleMixer}
|
||||
className="inline-flex items-center gap-2 text-xs text-white/70 transition hover:text-white"
|
||||
>
|
||||
<span>{isMixerOpen ? 'Mixer 접기' : 'Mixer 펼치기'}</span>
|
||||
<span>{isMixerOpen ? soundPresetControls.mixerClose : soundPresetControls.mixerOpen}</span>
|
||||
<span className="inline-flex items-center rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-white/86 ring-1 ring-white/20">
|
||||
더미
|
||||
{soundPresetControls.mock}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isMixerOpen ? (
|
||||
<div className="space-y-3 rounded-xl border border-white/14 bg-white/6 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white/78">마스터 볼륨</span>
|
||||
<span className="text-xs text-white/78">{soundPresetControls.masterVolume}</span>
|
||||
<span className="text-[11px] text-white/58">{masterVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
@@ -90,11 +86,11 @@ export const SoundPresetControls = ({
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-white/12 pt-2">
|
||||
<p className="text-xs text-white/78">뮤트</p>
|
||||
<p className="text-xs text-white/78">{soundPresetControls.mute}</p>
|
||||
<Toggle
|
||||
checked={isMuted}
|
||||
onChange={onMuteChange}
|
||||
ariaLabel="마스터 뮤트 토글"
|
||||
ariaLabel={soundPresetControls.muteToggleAriaLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { statsApi, type FocusStatsSummary } from '../api/statsApi';
|
||||
|
||||
type StatsSource = 'api' | 'mock';
|
||||
@@ -55,7 +56,7 @@ export const useFocusStats = (): UseFocusStatsResult => {
|
||||
setError(null);
|
||||
} catch (nextError) {
|
||||
const message =
|
||||
nextError instanceof Error ? nextError.message : '통계를 불러오지 못했어요.';
|
||||
nextError instanceof Error ? nextError.message : copy.stats.loadFailed;
|
||||
setSummary(buildMockSummary());
|
||||
setSource('mock');
|
||||
setError(message);
|
||||
|
||||
Reference in New Issue
Block a user