refactor(i18n): 사용자 문구 참조를 중앙화
This commit is contained in:
@@ -1,23 +1,26 @@
|
||||
import Link from "next/link";
|
||||
import { SocialLoginGroup } from "@/features/auth/components/SocialLoginGroup";
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = copy;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col justify-center items-center p-6 selection:bg-brand-soft/50">
|
||||
|
||||
{/* 상단 로고 (홈으로 돌아가기) */}
|
||||
<Link href="/" className="mb-12 text-2xl font-bold text-brand-dark tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<span className="text-3xl">🪴</span> VibeRoom
|
||||
<span className="text-3xl">🪴</span> {copy.appName}
|
||||
</Link>
|
||||
|
||||
{/* 로그인 카드 컨테이너 */}
|
||||
<div className="w-full max-w-md bg-white rounded-3xl shadow-sm border border-brand-dark/10 p-8 md:p-10">
|
||||
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-2xl font-bold text-brand-dark mb-3">다시 오셨군요!</h1>
|
||||
<h1 className="text-2xl font-bold text-brand-dark mb-3">{login.title}</h1>
|
||||
<p className="text-brand-dark/60 text-sm">
|
||||
비밀번호를 외울 필요 없이,<br />
|
||||
사용 중인 계정으로 3초 만에 시작하세요.
|
||||
{login.descriptionFirstLine}<br />
|
||||
{login.descriptionSecondLine}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -28,8 +31,8 @@ export default function LoginPage() {
|
||||
<SocialLoginGroup />
|
||||
|
||||
<div className="mt-10 text-center text-xs text-brand-dark/40 leading-relaxed">
|
||||
로그인함으로써 VibeRoom의 <br className="md:hidden" />
|
||||
<a href="#" className="underline hover:text-brand-dark/70">이용약관</a> 및 <a href="#" className="underline hover:text-brand-dark/70">개인정보처리방침</a>에 동의하게 됩니다.
|
||||
{login.agreementPrefix} <br className="md:hidden" />
|
||||
<a href="#" className="underline hover:text-brand-dark/70">{login.terms}</a> {login.agreementAnd} <a href="#" className="underline hover:text-brand-dark/70">{login.privacy}</a>{login.agreementSuffix}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { adminApi, type SceneMediaAssetUploadResponse, type SoundMediaAssetUploadResponse } from '@/features/admin/api/adminApi';
|
||||
import type { AuthResponse } from '@/features/auth/types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { Button } from '@/shared/ui';
|
||||
|
||||
const ADMIN_STORAGE_KEY = 'vr_admin_session';
|
||||
@@ -20,20 +21,7 @@ type NavItem = {
|
||||
section: string;
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
id: 'scene',
|
||||
title: '이미지 등록',
|
||||
subtitle: 'Scene background assets',
|
||||
section: 'MEDIA',
|
||||
},
|
||||
{
|
||||
id: 'sound',
|
||||
title: '오디오 등록',
|
||||
subtitle: 'Loop and preview assets',
|
||||
section: 'MEDIA',
|
||||
},
|
||||
];
|
||||
const navItems: NavItem[] = [...copy.admin.navItems];
|
||||
|
||||
const readStoredSession = (): AuthResponse | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -76,48 +64,26 @@ const textareaClassName =
|
||||
'min-h-32 w-full rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100';
|
||||
|
||||
const getViewMeta = (view: AdminView) => {
|
||||
if (view === 'scene') {
|
||||
return {
|
||||
eyebrow: 'Image Registration',
|
||||
title: 'Scene 이미지 등록',
|
||||
description:
|
||||
'Space에 들어온 사용자가 선택하는 배경 이미지 세트를 등록합니다. cardFile과 stageFile은 최초 생성 시 필수입니다.',
|
||||
accent: 'sky',
|
||||
statTitle: 'Image Pipeline',
|
||||
statValue: 'Scene Assets',
|
||||
statHint: 'R2 / Manifest Sync',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: 'Audio Registration',
|
||||
title: 'Sound 오디오 등록',
|
||||
description:
|
||||
'Loop, preview, fallback 오디오를 등록합니다. loopFile은 최초 생성 시 필수이며 기본 볼륨과 duration 메타데이터를 함께 저장합니다.',
|
||||
accent: 'emerald',
|
||||
statTitle: 'Audio Pipeline',
|
||||
statValue: 'Sound Assets',
|
||||
statHint: 'Loop / Preview / Fallback',
|
||||
};
|
||||
return copy.admin.views[view];
|
||||
};
|
||||
|
||||
const formatResultSummary = (uploadResult: UploadResult | null) => {
|
||||
if (!uploadResult) {
|
||||
return '아직 업로드 작업이 없습니다.';
|
||||
return copy.admin.inspector.noUploadSummary;
|
||||
}
|
||||
|
||||
if (uploadResult.type === 'scene') {
|
||||
return `${uploadResult.payload.sceneId} 이미지 세트가 최신 버전 ${uploadResult.payload.assetVersion}로 반영되었습니다.`;
|
||||
return copy.admin.messages.sceneSummary(uploadResult.payload.sceneId, uploadResult.payload.assetVersion);
|
||||
}
|
||||
|
||||
return `${uploadResult.payload.presetId} 오디오 세트가 최신 버전 ${uploadResult.payload.assetVersion}로 반영되었습니다.`;
|
||||
return copy.admin.messages.soundSummary(uploadResult.payload.presetId, uploadResult.payload.assetVersion);
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const [session, setSession] = useState<AuthResponse | null>(null);
|
||||
const [activeView, setActiveView] = useState<AdminView>('scene');
|
||||
const [loginId, setLoginId] = useState('qwer1234');
|
||||
const [password, setPassword] = useState('qwer1234!');
|
||||
const [loginId, setLoginId] = useState<string>(copy.admin.defaultLoginId);
|
||||
const [password, setPassword] = useState<string>(copy.admin.defaultPassword);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginPending, setLoginPending] = useState(false);
|
||||
const [scenePending, setScenePending] = useState(false);
|
||||
@@ -142,13 +108,13 @@ export default function AdminPage() {
|
||||
});
|
||||
|
||||
if (response.user?.grade !== 'ADMIN') {
|
||||
throw new Error('ADMIN 권한이 없는 계정입니다.');
|
||||
throw new Error(copy.admin.messages.nonAdmin);
|
||||
}
|
||||
|
||||
setSession(response);
|
||||
storeSession(response);
|
||||
} catch (error) {
|
||||
setLoginError(error instanceof Error ? error.message : '로그인에 실패했습니다.');
|
||||
setLoginError(error instanceof Error ? error.message : copy.admin.messages.loginFailed);
|
||||
} finally {
|
||||
setLoginPending(false);
|
||||
}
|
||||
@@ -166,7 +132,7 @@ export default function AdminPage() {
|
||||
const handleSceneUpload = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!session?.accessToken) {
|
||||
setSceneMessage('먼저 관리자 로그인을 해주세요.');
|
||||
setSceneMessage(copy.admin.messages.loginRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,7 +145,7 @@ export default function AdminPage() {
|
||||
const sceneId = String(source.get('sceneId') ?? '').trim();
|
||||
|
||||
if (!sceneId) {
|
||||
throw new Error('sceneId를 입력해주세요.');
|
||||
throw new Error(copy.admin.messages.sceneIdRequired);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -199,10 +165,10 @@ export default function AdminPage() {
|
||||
|
||||
const response = await adminApi.uploadScene(sceneId, formData, session.accessToken);
|
||||
setUploadResult({ type: 'scene', payload: response });
|
||||
setSceneMessage(`scene "${sceneId}" 업로드가 완료되었습니다.`);
|
||||
setSceneMessage(copy.admin.messages.sceneUploadDone(sceneId));
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
setSceneMessage(error instanceof Error ? error.message : 'scene 업로드에 실패했습니다.');
|
||||
setSceneMessage(error instanceof Error ? error.message : copy.admin.messages.sceneUploadFailed);
|
||||
} finally {
|
||||
setScenePending(false);
|
||||
}
|
||||
@@ -211,7 +177,7 @@ export default function AdminPage() {
|
||||
const handleSoundUpload = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!session?.accessToken) {
|
||||
setSoundMessage('먼저 관리자 로그인을 해주세요.');
|
||||
setSoundMessage(copy.admin.messages.loginRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -224,7 +190,7 @@ export default function AdminPage() {
|
||||
const presetId = String(source.get('presetId') ?? '').trim();
|
||||
|
||||
if (!presetId) {
|
||||
throw new Error('presetId를 입력해주세요.');
|
||||
throw new Error(copy.admin.messages.presetIdRequired);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -244,10 +210,10 @@ export default function AdminPage() {
|
||||
|
||||
const response = await adminApi.uploadSound(presetId, formData, session.accessToken);
|
||||
setUploadResult({ type: 'sound', payload: response });
|
||||
setSoundMessage(`sound "${presetId}" 업로드가 완료되었습니다.`);
|
||||
setSoundMessage(copy.admin.messages.soundUploadDone(presetId));
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
setSoundMessage(error instanceof Error ? error.message : 'sound 업로드에 실패했습니다.');
|
||||
setSoundMessage(error instanceof Error ? error.message : copy.admin.messages.soundUploadFailed);
|
||||
} finally {
|
||||
setSoundPending(false);
|
||||
}
|
||||
@@ -267,15 +233,15 @@ export default function AdminPage() {
|
||||
V
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-semibold tracking-tight">VibeRoom</p>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Admin Console</p>
|
||||
<p className="text-xl font-semibold tracking-tight">{copy.appName}</p>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">{copy.admin.consoleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 space-y-8">
|
||||
<div>
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||
Media Operations
|
||||
{copy.admin.mediaOperations}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item, index) => (
|
||||
@@ -296,36 +262,36 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 p-5">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">Access</p>
|
||||
<p className="mt-3 text-2xl font-semibold">Admin Only</p>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">{copy.admin.access}</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{copy.admin.accessTitle}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">
|
||||
로그인 후 업로드 토큰으로 scene 이미지와 sound 오디오를 바로 R2에 반영합니다.
|
||||
{copy.admin.accessDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">
|
||||
Local admin credentials
|
||||
{copy.admin.localCredentialsHint}
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<section className="flex items-center justify-center px-6 py-10">
|
||||
<div className="w-full max-w-md rounded-[28px] border border-slate-200 bg-white px-8 py-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]">
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-600">Sign In</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-600">{copy.admin.signInEyebrow}</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
|
||||
관리자 로그인
|
||||
{copy.admin.loginTitle}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">
|
||||
관리자 권한이 확인되면 좌측 사이드바 기반 대시보드가 열립니다.
|
||||
{copy.admin.loginDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleLogin}>
|
||||
<div>
|
||||
<label htmlFor="admin-login-id" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
아이디
|
||||
{copy.admin.loginIdLabel}
|
||||
</label>
|
||||
<input
|
||||
id="admin-login-id"
|
||||
@@ -333,12 +299,12 @@ export default function AdminPage() {
|
||||
value={loginId}
|
||||
onChange={(event) => setLoginId(event.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="qwer1234"
|
||||
placeholder={copy.admin.defaultLoginId}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="admin-login-password" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
비밀번호
|
||||
{copy.admin.loginPasswordLabel}
|
||||
</label>
|
||||
<input
|
||||
id="admin-login-password"
|
||||
@@ -347,7 +313,7 @@ export default function AdminPage() {
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="qwer1234!"
|
||||
placeholder={copy.admin.defaultPassword}
|
||||
/>
|
||||
</div>
|
||||
{loginError ? (
|
||||
@@ -356,7 +322,7 @@ export default function AdminPage() {
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="submit" size="full" className="h-12 rounded-xl" disabled={loginPending}>
|
||||
{loginPending ? '로그인 중...' : '대시보드 열기'}
|
||||
{loginPending ? copy.admin.loggingIn : copy.admin.openDashboard}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -375,15 +341,15 @@ export default function AdminPage() {
|
||||
V
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold tracking-tight">VibeRoom</p>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Media Admin</p>
|
||||
<p className="text-2xl font-semibold tracking-tight">{copy.appName}</p>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">{copy.admin.mediaAdminLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mt-10 flex-1">
|
||||
<div className="px-3">
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||
Media
|
||||
{copy.admin.navItems[0].section}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -414,16 +380,16 @@ export default function AdminPage() {
|
||||
|
||||
<div className="mt-10 px-3">
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||
Session
|
||||
{copy.admin.sessionSection}
|
||||
</p>
|
||||
<div className="space-y-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Admin</p>
|
||||
<p className="mt-1 font-medium text-white">{session.user?.name ?? '관리자'}</p>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{copy.admin.sessionAdminLabel}</p>
|
||||
<p className="mt-1 font-medium text-white">{session.user?.name ?? copy.common.admin}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Role</p>
|
||||
<p className="mt-1 font-medium text-emerald-300">{session.user?.grade ?? 'ADMIN'} Access</p>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{copy.admin.sessionRoleLabel}</p>
|
||||
<p className="mt-1 font-medium text-emerald-300">{session.user?.grade ?? 'ADMIN'}{copy.admin.roleAccessSuffix}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -437,7 +403,7 @@ export default function AdminPage() {
|
||||
className="h-11 justify-start rounded-xl bg-white/6 px-4 text-white hover:bg-white/10"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
로그아웃
|
||||
{copy.admin.logout}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -454,7 +420,7 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<div className="relative w-full max-w-md">
|
||||
<input
|
||||
value={activeView === 'scene' ? 'scene assets' : 'sound assets'}
|
||||
value={activeView === 'scene' ? copy.admin.searchValues.scene : copy.admin.searchValues.sound}
|
||||
readOnly
|
||||
className="h-11 w-full rounded-xl border border-slate-200 bg-slate-50 pl-11 pr-4 text-sm text-slate-500 outline-none"
|
||||
/>
|
||||
@@ -467,14 +433,14 @@ export default function AdminPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden items-center gap-2 rounded-full bg-slate-100 px-3 py-2 text-xs font-medium text-slate-500 md:flex">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Manifest Ready
|
||||
{copy.admin.manifestReady}
|
||||
</div>
|
||||
<div className="flex h-11 items-center gap-3 rounded-full border border-slate-200 bg-white px-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">
|
||||
A
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-semibold text-slate-900">{session.user?.name ?? 'Admin'}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{session.user?.name ?? copy.common.admin}</p>
|
||||
<p className="text-xs text-slate-500">{session.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -503,20 +469,20 @@ export default function AdminPage() {
|
||||
<p className="mt-2 text-sm text-slate-500">{activeMeta.statHint}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
|
||||
<p className="text-sm font-medium text-slate-500">Current Role</p>
|
||||
<p className="text-sm font-medium text-slate-500">{copy.admin.inspector.currentRoleTitle}</p>
|
||||
<p className="mt-6 text-3xl font-semibold tracking-tight text-slate-950">
|
||||
{session.user?.grade ?? 'ADMIN'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">Bearer token enabled session</p>
|
||||
<p className="mt-2 text-sm text-slate-500">{copy.admin.inspector.bearerTokenSession}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeView === 'scene' ? (
|
||||
<form onSubmit={handleSceneUpload} className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<p className="text-lg font-semibold text-slate-950">이미지 등록 워크스페이스</p>
|
||||
<p className="text-lg font-semibold text-slate-950">{copy.admin.views.scene.workspaceTitle}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
왼쪽 메뉴는 유지되고, 이 중앙 영역만 `이미지 등록` 작업으로 전환됩니다.
|
||||
{copy.admin.views.scene.workspaceDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -525,61 +491,61 @@ export default function AdminPage() {
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="scene-id" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
Scene ID
|
||||
{copy.admin.views.scene.sceneIdLabel}
|
||||
</label>
|
||||
<input id="scene-id" name="sceneId" className={fieldClassName} placeholder="rain-window" />
|
||||
<input id="scene-id" name="sceneId" className={fieldClassName} placeholder={copy.admin.views.scene.sceneIdPlaceholder} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
Placeholder Gradient
|
||||
{copy.admin.views.scene.placeholderGradientLabel}
|
||||
</label>
|
||||
<input
|
||||
name="placeholderGradient"
|
||||
className={fieldClassName}
|
||||
placeholder="linear-gradient(160deg, #1e293b 0%, #0f172a 100%)"
|
||||
placeholder={copy.admin.views.scene.placeholderGradientPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Card Image</label>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.cardImageLabel}</label>
|
||||
<input name="cardFile" type="file" accept="image/*" className={fileClassName} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Stage Image</label>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.stageImageLabel}</label>
|
||||
<input name="stageFile" type="file" accept="image/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Mobile Stage</label>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.mobileStageLabel}</label>
|
||||
<input name="mobileStageFile" type="file" accept="image/*" className={fileClassName} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">HD Stage</label>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.hdStageLabel}</label>
|
||||
<input name="hdStageFile" type="file" accept="image/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Blur Data URL</label>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.blurDataUrlLabel}</label>
|
||||
<textarea
|
||||
name="blurDataUrl"
|
||||
rows={6}
|
||||
className={textareaClassName}
|
||||
placeholder="data:image/jpeg;base64,..."
|
||||
placeholder={copy.admin.views.scene.blurDataUrlPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
|
||||
<p className="text-sm font-semibold text-slate-900">업로드 메모</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.admin.views.scene.notesTitle}</p>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
|
||||
<li>최초 생성 시 `cardFile`, `stageFile`은 필수입니다.</li>
|
||||
<li>모바일과 HD 파일은 선택값이며 빠지면 기존 값을 유지합니다.</li>
|
||||
<li>업로드 즉시 R2 경로와 manifest 응답 값이 갱신됩니다.</li>
|
||||
{copy.admin.views.scene.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{currentMessage ? (
|
||||
@@ -593,7 +559,7 @@ export default function AdminPage() {
|
||||
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
|
||||
disabled={scenePending}
|
||||
>
|
||||
{scenePending ? '이미지 업로드 중...' : '이미지 등록'}
|
||||
{scenePending ? copy.admin.views.scene.pending : copy.admin.views.scene.submit}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -601,9 +567,9 @@ export default function AdminPage() {
|
||||
) : (
|
||||
<form onSubmit={handleSoundUpload} className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<p className="text-lg font-semibold text-slate-950">오디오 등록 워크스페이스</p>
|
||||
<p className="text-lg font-semibold text-slate-950">{copy.admin.views.sound.workspaceTitle}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
좌측 네비게이션은 고정되고, 이 중앙 영역만 `오디오 등록` 작업으로 바뀝니다.
|
||||
{copy.admin.views.sound.workspaceDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -612,43 +578,43 @@ export default function AdminPage() {
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<label htmlFor="preset-id" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
Preset ID
|
||||
{copy.admin.views.sound.presetIdLabel}
|
||||
</label>
|
||||
<input id="preset-id" name="presetId" className={fieldClassName} placeholder="rain-focus" />
|
||||
<input id="preset-id" name="presetId" className={fieldClassName} placeholder={copy.admin.views.sound.presetIdPlaceholder} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Duration Sec</label>
|
||||
<input name="durationSec" type="number" min={0} className={fieldClassName} placeholder="1800" />
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.durationLabel}</label>
|
||||
<input name="durationSec" type="number" min={0} className={fieldClassName} placeholder={copy.admin.views.sound.durationPlaceholder} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Default Volume</label>
|
||||
<input name="defaultVolume" type="number" min={0} max={100} className={fieldClassName} placeholder="60" />
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.defaultVolumeLabel}</label>
|
||||
<input name="defaultVolume" type="number" min={0} max={100} className={fieldClassName} placeholder={copy.admin.views.sound.defaultVolumePlaceholder} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Loop File</label>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.loopFileLabel}</label>
|
||||
<input name="loopFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Preview File</label>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.previewFileLabel}</label>
|
||||
<input name="previewFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Fallback Loop File</label>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.fallbackLoopFileLabel}</label>
|
||||
<input name="fallbackLoopFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
|
||||
<p className="text-sm font-semibold text-slate-900">업로드 메모</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.admin.views.sound.notesTitle}</p>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
|
||||
<li>최초 생성 시 `loopFile`은 필수입니다.</li>
|
||||
<li>`preview`, `fallback`은 선택값이며 기존 메타데이터를 유지할 수 있습니다.</li>
|
||||
<li>`defaultVolume`, `durationSec`는 manifest 응답에 함께 반영됩니다.</li>
|
||||
{copy.admin.views.sound.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{currentMessage ? (
|
||||
@@ -662,7 +628,7 @@ export default function AdminPage() {
|
||||
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
|
||||
disabled={soundPending}
|
||||
>
|
||||
{soundPending ? '오디오 업로드 중...' : '오디오 등록'}
|
||||
{soundPending ? copy.admin.views.sound.pending : copy.admin.views.sound.submit}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,21 +639,21 @@ export default function AdminPage() {
|
||||
<aside className="space-y-6">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-5 py-4">
|
||||
<p className="text-sm font-semibold text-slate-950">최근 응답</p>
|
||||
<p className="text-sm font-semibold text-slate-950">{copy.admin.inspector.recentResponse}</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm leading-6 text-slate-500">
|
||||
{formatResultSummary(uploadResult)}
|
||||
</p>
|
||||
<pre className="mt-4 max-h-[340px] overflow-auto rounded-xl bg-[#171821] px-4 py-4 text-[11px] leading-6 text-slate-100">
|
||||
{uploadResult ? JSON.stringify(uploadResult, null, 2) : '업로드 응답이 아직 없습니다.'}
|
||||
{uploadResult ? JSON.stringify(uploadResult, null, 2) : copy.admin.inspector.noUploadPayload}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-5 py-4">
|
||||
<p className="text-sm font-semibold text-slate-950">세션 토큰</p>
|
||||
<p className="text-sm font-semibold text-slate-950">{copy.admin.inspector.sessionToken}</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="break-all text-xs leading-6 text-slate-500">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Noto_Sans_KR } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { Providers } from './providers';
|
||||
|
||||
// 1. Noto Sans KR 폰트 설정 (라틴어, 프랑스어, 한국어 등 다국어 지원 베이스)
|
||||
@@ -12,8 +13,8 @@ const notoSans = Noto_Sans_KR({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'VibeRoom - 당신만의 편안한 몰입 공간',
|
||||
description: '프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스. 작업 타이머, 세션 관리, 그리고 느슨한 연대를 통해 당신의 리듬을 찾아보세요.',
|
||||
title: copy.metadata.title,
|
||||
description: copy.metadata.description,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_MEDIA_MANIFEST } from '../model/mockMediaManifest';
|
||||
import { normalizeMediaManifest } from '../model/resolveMediaAsset';
|
||||
import type { MediaManifest } from '../model/types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
const MEDIA_MANIFEST_URL = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL;
|
||||
|
||||
@@ -17,7 +18,7 @@ export const mediaManifestApi = {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('미디어 manifest를 불러오지 못했어요.');
|
||||
throw new Error(copy.media.manifestLoadFailed);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as Partial<MediaManifest>;
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import type { ProFeatureCard } from './types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const PRO_LOCKED_ROOM_IDS: string[] = [];
|
||||
export const PRO_LOCKED_TIMER_LABELS: string[] = [];
|
||||
export const PRO_LOCKED_SOUND_IDS: string[] = [];
|
||||
|
||||
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [
|
||||
{
|
||||
id: 'scene-packs',
|
||||
name: 'Scene Packs',
|
||||
description: '프리미엄 공간 묶음과 장면 변주',
|
||||
},
|
||||
{
|
||||
id: 'sound-packs',
|
||||
name: 'Sound Packs',
|
||||
description: '확장 사운드 프리셋 묶음',
|
||||
},
|
||||
{
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
description: '내 기본 세팅 저장/불러오기',
|
||||
},
|
||||
];
|
||||
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [...copy.plan.proFeatureCards];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { SceneTheme } from './types';
|
||||
|
||||
const HUB_CURATION_ORDER = [
|
||||
@@ -14,14 +15,14 @@ const HUB_RECOMMENDED_SCENE_COUNT = 3;
|
||||
export const SCENE_THEMES: SceneTheme[] = [
|
||||
{
|
||||
id: 'rain-window',
|
||||
name: '비 오는 창가',
|
||||
description: '빗소리 위로 스탠드 조명이 부드럽게 번집니다.',
|
||||
tags: ['저자극', '감성'],
|
||||
recommendedSound: 'Rain Focus',
|
||||
name: copy.scenes[0].name,
|
||||
description: copy.scenes[0].description,
|
||||
tags: [...copy.scenes[0].tags],
|
||||
recommendedSound: copy.scenes[0].recommendedSound,
|
||||
recommendedSoundPresetId: 'rain-focus',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '밤',
|
||||
vibeLabel: '잔잔함',
|
||||
recommendedTime: copy.scenes[0].recommendedTime,
|
||||
vibeLabel: copy.scenes[0].vibeLabel,
|
||||
hubColor: '#D6E6F7',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/16763533/pexels-photo-16763533.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -36,14 +37,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'dawn-cafe',
|
||||
name: '새벽 카페',
|
||||
description: '첫 커피 향처럼 잔잔하고 따뜻한 좌석.',
|
||||
tags: ['감성', '딥워크'],
|
||||
recommendedSound: 'Cafe Murmur',
|
||||
name: copy.scenes[1].name,
|
||||
description: copy.scenes[1].description,
|
||||
tags: [...copy.scenes[1].tags],
|
||||
recommendedSound: copy.scenes[1].recommendedSound,
|
||||
recommendedSoundPresetId: 'cafe-work',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '새벽',
|
||||
vibeLabel: '포근함',
|
||||
recommendedTime: copy.scenes[1].recommendedTime,
|
||||
vibeLabel: copy.scenes[1].vibeLabel,
|
||||
hubColor: '#F5DDCB',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/18340237/pexels-photo-18340237.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -58,14 +59,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'quiet-library',
|
||||
name: '도서관',
|
||||
description: '넘기는 종이 소리만 들리는 정돈된 책상.',
|
||||
tags: ['저자극', '딥워크'],
|
||||
recommendedSound: 'Deep White',
|
||||
name: copy.scenes[2].name,
|
||||
description: copy.scenes[2].description,
|
||||
tags: [...copy.scenes[2].tags],
|
||||
recommendedSound: copy.scenes[2].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: '오후',
|
||||
vibeLabel: '몰입',
|
||||
recommendedTime: copy.scenes[2].recommendedTime,
|
||||
vibeLabel: copy.scenes[2].vibeLabel,
|
||||
hubColor: '#DCE4D1',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/31390421/pexels-photo-31390421.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -80,14 +81,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'wave-sound',
|
||||
name: '파도 소리',
|
||||
description: '잔잔한 해변 위로 호흡을 고르는 공간.',
|
||||
tags: ['움직임 적음', '감성'],
|
||||
recommendedSound: 'Ocean Breath',
|
||||
name: copy.scenes[3].name,
|
||||
description: copy.scenes[3].description,
|
||||
tags: [...copy.scenes[3].tags],
|
||||
recommendedSound: copy.scenes[3].recommendedSound,
|
||||
recommendedSoundPresetId: 'ocean-calm',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '밤',
|
||||
vibeLabel: '차분함',
|
||||
recommendedTime: copy.scenes[3].recommendedTime,
|
||||
vibeLabel: copy.scenes[3].vibeLabel,
|
||||
hubColor: '#CFE9EA',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/12715501/pexels-photo-12715501.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -102,14 +103,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'green-forest',
|
||||
name: '숲',
|
||||
description: '바람이 나뭇잎을 스치는 소리로 마음을 낮춥니다.',
|
||||
tags: ['저자극', '움직임 적음'],
|
||||
recommendedSound: 'Forest Hush',
|
||||
name: copy.scenes[4].name,
|
||||
description: copy.scenes[4].description,
|
||||
tags: [...copy.scenes[4].tags],
|
||||
recommendedSound: copy.scenes[4].recommendedSound,
|
||||
recommendedSoundPresetId: 'rain-focus',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: '오전',
|
||||
vibeLabel: '맑음',
|
||||
recommendedTime: copy.scenes[4].recommendedTime,
|
||||
vibeLabel: copy.scenes[4].vibeLabel,
|
||||
hubColor: '#D1E7C9',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/34503448/pexels-photo-34503448.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -124,14 +125,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'fireplace',
|
||||
name: '벽난로',
|
||||
description: '작은 불꽃이 주는 리듬으로 집중을 붙잡습니다.',
|
||||
tags: ['감성', '저자극'],
|
||||
recommendedSound: 'Fireplace',
|
||||
name: copy.scenes[5].name,
|
||||
description: copy.scenes[5].description,
|
||||
tags: [...copy.scenes[5].tags],
|
||||
recommendedSound: copy.scenes[5].recommendedSound,
|
||||
recommendedSoundPresetId: 'fireplace',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '밤',
|
||||
vibeLabel: '온기',
|
||||
recommendedTime: copy.scenes[5].recommendedTime,
|
||||
vibeLabel: copy.scenes[5].vibeLabel,
|
||||
hubColor: '#F2D4C0',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/14353716/pexels-photo-14353716.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -146,14 +147,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'city-night',
|
||||
name: '도시 야경',
|
||||
description: '유리창 너머 야경이 멀리 흐르는 고요한 밤.',
|
||||
tags: ['딥워크', '감성'],
|
||||
recommendedSound: 'Night Lo-fi',
|
||||
name: copy.scenes[6].name,
|
||||
description: copy.scenes[6].description,
|
||||
tags: [...copy.scenes[6].tags],
|
||||
recommendedSound: copy.scenes[6].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: '심야',
|
||||
vibeLabel: '고요함',
|
||||
recommendedTime: copy.scenes[6].recommendedTime,
|
||||
vibeLabel: copy.scenes[6].vibeLabel,
|
||||
hubColor: '#D9D3ED',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/17663181/pexels-photo-17663181.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -168,14 +169,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'snow-mountain',
|
||||
name: '설산',
|
||||
description: '차분한 공기와 선명한 수평선이 머리를 맑게 합니다.',
|
||||
tags: ['움직임 적음', '딥워크'],
|
||||
recommendedSound: 'Cold Wind',
|
||||
name: copy.scenes[7].name,
|
||||
description: copy.scenes[7].description,
|
||||
tags: [...copy.scenes[7].tags],
|
||||
recommendedSound: copy.scenes[7].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: '새벽',
|
||||
vibeLabel: '선명함',
|
||||
recommendedTime: copy.scenes[7].recommendedTime,
|
||||
vibeLabel: copy.scenes[7].vibeLabel,
|
||||
hubColor: '#D8E7F3',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/34340672/pexels-photo-34340672.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -190,14 +191,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'sun-window',
|
||||
name: '창가',
|
||||
description: '햇살이 들어오는 간결한 책상, 부담 없는 시작.',
|
||||
tags: ['저자극', '딥워크'],
|
||||
recommendedSound: 'Soft Daylight',
|
||||
name: copy.scenes[8].name,
|
||||
description: copy.scenes[8].description,
|
||||
tags: [...copy.scenes[8].tags],
|
||||
recommendedSound: copy.scenes[8].recommendedSound,
|
||||
recommendedSoundPresetId: 'silent',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '오후',
|
||||
vibeLabel: '가벼움',
|
||||
recommendedTime: copy.scenes[8].recommendedTime,
|
||||
vibeLabel: copy.scenes[8].vibeLabel,
|
||||
hubColor: '#F6EDC7',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/34833126/pexels-photo-34833126.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -212,14 +213,14 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'outer-space',
|
||||
name: '우주',
|
||||
description: '별빛만 남긴 어둠 속에서 깊게 잠수합니다.',
|
||||
tags: ['딥워크', '감성'],
|
||||
recommendedSound: 'Deep Drone',
|
||||
name: copy.scenes[9].name,
|
||||
description: copy.scenes[9].description,
|
||||
tags: [...copy.scenes[9].tags],
|
||||
recommendedSound: copy.scenes[9].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '90-20',
|
||||
recommendedTime: '심야',
|
||||
vibeLabel: '깊음',
|
||||
recommendedTime: copy.scenes[9].recommendedTime,
|
||||
vibeLabel: copy.scenes[9].vibeLabel,
|
||||
hubColor: '#D4DCF4',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/18537868/pexels-photo-18537868.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
|
||||
@@ -7,90 +7,24 @@ import type {
|
||||
SoundPreset,
|
||||
TimerPreset,
|
||||
} from './types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const TODAY_ONE_LINER = '오늘의 한 줄: 완벽보다 시작, 한 조각이면 충분해요.';
|
||||
export const TODAY_ONE_LINER = copy.session.todayOneLiner;
|
||||
|
||||
export const GOAL_CHIPS: GoalChip[] = [
|
||||
{ id: 'mail-3', label: '메일 3개' },
|
||||
{ id: 'doc-1p', label: '문서 1p' },
|
||||
{ id: 'code-1-function', label: '코딩 1함수' },
|
||||
{ id: 'tidy-10m', label: '정리 10분' },
|
||||
{ id: 'reading-15m', label: '독서 15분' },
|
||||
{ id: 'resume-1paragraph', label: '이력서 1문단' },
|
||||
];
|
||||
export const GOAL_CHIPS: GoalChip[] = [...copy.session.goalChips];
|
||||
|
||||
export const CHECK_IN_PHRASES: CheckInPhrase[] = [
|
||||
{ id: 'arrived', text: '지금 들어왔어요' },
|
||||
{ id: 'sprint-25', text: '25분만 달릴게요' },
|
||||
{ id: 'on-break', text: '휴식 중' },
|
||||
{ id: 'back-focus', text: '다시 집중!' },
|
||||
{ id: 'slow-day', text: '오늘은 천천히' },
|
||||
];
|
||||
export const CHECK_IN_PHRASES: CheckInPhrase[] = [...copy.session.checkInPhrases];
|
||||
|
||||
export const REACTION_OPTIONS: ReactionOption[] = [
|
||||
{ id: 'thumbs-up', emoji: '👍', label: '응원해요' },
|
||||
{ id: 'fire', emoji: '🔥', label: '집중 모드' },
|
||||
{ id: 'clap', emoji: '👏', label: '잘하고 있어요' },
|
||||
{ id: 'heart-hands', emoji: '🫶', label: '연결되어 있어요' },
|
||||
];
|
||||
export const REACTION_OPTIONS: ReactionOption[] = [...copy.session.reactionOptions];
|
||||
|
||||
export const SOUND_PRESETS: SoundPreset[] = [
|
||||
{ id: 'deep-white', label: 'Deep White' },
|
||||
{ id: 'rain-focus', label: 'Rain Focus' },
|
||||
{ id: 'cafe-work', label: 'Cafe Work' },
|
||||
{ id: 'ocean-calm', label: 'Ocean Calm' },
|
||||
{ id: 'fireplace', label: 'Fireplace' },
|
||||
{ id: 'silent', label: 'Silent' },
|
||||
];
|
||||
export const SOUND_PRESETS: SoundPreset[] = [...copy.session.soundPresets];
|
||||
|
||||
export const TIMER_PRESETS: TimerPreset[] = [
|
||||
{ id: '25-5', label: '25/5', focusMinutes: 25, breakMinutes: 5 },
|
||||
{ id: '50-10', label: '50/10', focusMinutes: 50, breakMinutes: 10 },
|
||||
{ id: '90-20', label: '90/20', focusMinutes: 90, breakMinutes: 20 },
|
||||
{ id: 'custom', label: '커스텀' },
|
||||
];
|
||||
export const TIMER_PRESETS: TimerPreset[] = [...copy.session.timerPresets];
|
||||
|
||||
export const DISTRACTION_DUMP_PLACEHOLDER = [
|
||||
'디자인 QA 요청 확인',
|
||||
'세금계산서 발행 메모',
|
||||
'오후 미팅 질문 1개 정리',
|
||||
];
|
||||
export const DISTRACTION_DUMP_PLACEHOLDER = [...copy.session.distractionDumpPlaceholder];
|
||||
|
||||
export const TODAY_STATS: FocusStatCard[] = [
|
||||
{ id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' },
|
||||
{ id: 'today-cycles', label: '완료한 사이클', value: '5회', delta: '+1' },
|
||||
{ id: 'today-entry', label: '입장 횟수', value: '3회', delta: '유지' },
|
||||
];
|
||||
export const TODAY_STATS: FocusStatCard[] = [...copy.session.todayStats];
|
||||
|
||||
export const WEEKLY_STATS: FocusStatCard[] = [
|
||||
{ id: 'week-focus', label: '최근 7일 집중 시간', value: '14h 20m', delta: '+2h 10m' },
|
||||
{ id: 'week-best-day', label: '최고 몰입일', value: '수요일', delta: '3h 30m' },
|
||||
{ id: 'week-consistency', label: '연속 달성', value: '4일', delta: '+1일' },
|
||||
];
|
||||
export const WEEKLY_STATS: FocusStatCard[] = [...copy.session.weeklyStats];
|
||||
|
||||
export const RECENT_THOUGHTS: RecentThought[] = [
|
||||
{
|
||||
id: 'thought-1',
|
||||
text: '내일 미팅 전에 제안서 첫 문단만 다시 다듬기',
|
||||
sceneName: '도서관',
|
||||
capturedAt: '방금 전',
|
||||
},
|
||||
{
|
||||
id: 'thought-2',
|
||||
text: '기획 문서의 핵심 흐름을 한 문장으로 정리해두기',
|
||||
sceneName: '비 오는 창가',
|
||||
capturedAt: '24분 전',
|
||||
},
|
||||
{
|
||||
id: 'thought-3',
|
||||
text: '오후에 확인할 이슈 번호만 메모하고 지금 작업 복귀',
|
||||
sceneName: '숲',
|
||||
capturedAt: '1시간 전',
|
||||
},
|
||||
{
|
||||
id: 'thought-4',
|
||||
text: '리뷰 코멘트는 오늘 17시 이후에 한 번에 처리',
|
||||
sceneName: '벽난로',
|
||||
capturedAt: '어제',
|
||||
},
|
||||
];
|
||||
export const RECENT_THOUGHTS: RecentThought[] = [...copy.session.recentThoughts];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const NOTIFICATION_INTENSITY_OPTIONS = ['조용함', '기본', '강함'] as const;
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const DEFAULT_PRESET_OPTIONS = [
|
||||
{ id: 'balanced', label: 'Balanced 25/5 + Rain Focus' },
|
||||
{ id: 'deep-work', label: 'Deep Work 50/10 + Deep White' },
|
||||
{ id: 'gentle', label: 'Gentle 25/5 + Silent' },
|
||||
] as const;
|
||||
export const NOTIFICATION_INTENSITY_OPTIONS = copy.settings.notificationIntensityOptions;
|
||||
|
||||
export const DEFAULT_PRESET_OPTIONS = copy.settings.defaultPresetOptions;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
|
||||
@@ -56,14 +57,14 @@ const readErrorMessage = async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
return `API 요청 실패: ${response.status}`;
|
||||
return copy.common.apiRequestFailed(response.status);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await response.json()) as ApiErrorPayload;
|
||||
return payload.message ?? payload.error ?? `API 요청 실패: ${response.status}`;
|
||||
return payload.message ?? payload.error ?? copy.common.apiRequestFailed(response.status);
|
||||
} catch {
|
||||
return `API 요청 실패: ${response.status}`;
|
||||
return copy.common.apiRequestFailed(response.status);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||
|
||||
@@ -50,7 +51,7 @@ export const Modal = ({
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="모달 닫기"
|
||||
aria-label={copy.modal.closeAriaLabel}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-900/52 backdrop-blur-[2px]"
|
||||
/>
|
||||
@@ -81,7 +82,7 @@ export const Modal = ({
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-brand-dark/16 bg-white/58 px-2.5 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/84 hover:text-brand-dark"
|
||||
>
|
||||
닫기
|
||||
{copy.modal.closeButton}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
|
||||
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||
import { Toggle } from '@/shared/ui';
|
||||
@@ -58,6 +59,7 @@ export const ControlCenterSheetWidget = ({
|
||||
onSelectProFeature,
|
||||
onLockedClick,
|
||||
}: ControlCenterSheetWidgetProps) => {
|
||||
const { controlCenter } = copy.space;
|
||||
const reducedMotion = useReducedMotion();
|
||||
const isPro = plan === 'pro';
|
||||
const interactiveMotionClass = reducedMotion
|
||||
@@ -74,7 +76,7 @@ export const ControlCenterSheetWidget = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
|
||||
<SectionTitle title="Background" description={selectedScene?.name ?? '기본 배경'} />
|
||||
<SectionTitle title={controlCenter.sectionTitles.background} description={selectedScene?.name ?? copy.common.defaultBackground} />
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-1 flex gap-2.5 overflow-x-auto px-1 pb-1.5 snap-x snap-mandatory scrollbar-none',
|
||||
@@ -116,7 +118,7 @@ export const ControlCenterSheetWidget = ({
|
||||
</section>
|
||||
|
||||
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
|
||||
<SectionTitle title="Time" description={selectedTimerLabel} />
|
||||
<SectionTitle title={controlCenter.sectionTitles.time} description={selectedTimerLabel} />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{timerPresets.slice(0, 3).map((preset) => {
|
||||
const selected = preset.label === selectedTimerLabel;
|
||||
@@ -145,8 +147,8 @@ export const ControlCenterSheetWidget = ({
|
||||
|
||||
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
|
||||
<SectionTitle
|
||||
title="Sound"
|
||||
description={SOUND_PRESETS.find((preset) => preset.id === selectedSoundPresetId)?.label ?? '기본'}
|
||||
title={controlCenter.sectionTitles.sound}
|
||||
description={SOUND_PRESETS.find((preset) => preset.id === selectedSoundPresetId)?.label ?? copy.common.default}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SOUND_PRESETS.slice(0, 6).map((preset) => {
|
||||
@@ -175,12 +177,12 @@ export const ControlCenterSheetWidget = ({
|
||||
</section>
|
||||
|
||||
<div className="space-y-1.5 rounded-xl border border-white/12 bg-white/[0.03] px-3 py-2.5">
|
||||
<p className="text-[11px] text-white/58">추천: {sceneRecommendedSoundLabel} · {sceneRecommendedTimerLabel}</p>
|
||||
<p className="text-[10px] text-white/48">추천 조합은 참고 정보로만 제공돼요.</p>
|
||||
<p className="text-[11px] text-white/58">{controlCenter.recommendation(sceneRecommendedSoundLabel, sceneRecommendedTimerLabel)}</p>
|
||||
<p className="text-[10px] text-white/48">{controlCenter.recommendationHint}</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 p-3 backdrop-blur-md">
|
||||
<SectionTitle title="Packs" description="확장/개인화" />
|
||||
<SectionTitle title={controlCenter.sectionTitles.packs} description={controlCenter.packsDescription} />
|
||||
<div className="space-y-1.5">
|
||||
{PRO_FEATURE_CARDS.map((feature) => {
|
||||
const locked = !isPro;
|
||||
@@ -213,13 +215,13 @@ export const ControlCenterSheetWidget = ({
|
||||
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 px-3 py-2.5 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-white/72">컨트롤 자동 숨김</p>
|
||||
<p className="mt-0.5 text-[10px] text-white/52">입력이 없으면 잠시 후 패널을 닫아요.</p>
|
||||
<p className="text-[11px] text-white/72">{controlCenter.autoHideTitle}</p>
|
||||
<p className="mt-0.5 text-[10px] text-white/52">{controlCenter.autoHideDescription}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={autoHideControls}
|
||||
onChange={onAutoHideControlsChange}
|
||||
ariaLabel="컨트롤 자동 숨김"
|
||||
ariaLabel={controlCenter.autoHideAriaLabel}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
DEFAULT_PRESET_OPTIONS,
|
||||
NOTIFICATION_INTENSITY_OPTIONS,
|
||||
} from '@/shared/config/settingsOptions';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useUserFocusPreferences } from '@/features/preferences';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
export const SettingsPanelWidget = () => {
|
||||
const { settings } = copy;
|
||||
const {
|
||||
preferences,
|
||||
isLoading,
|
||||
@@ -22,12 +24,12 @@ export const SettingsPanelWidget = () => {
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_82%_0%,rgba(167,204,237,0.42),transparent_50%),radial-gradient(circle_at_12%_8%,rgba(191,219,254,0.4),transparent_46%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_54%,#e8f1fa_100%)] text-brand-dark">
|
||||
<div className="mx-auto w-full max-w-4xl px-4 pb-10 pt-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between rounded-xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<h1 className="text-xl font-semibold">{settings.title}</h1>
|
||||
<Link
|
||||
href="/app"
|
||||
className="rounded-lg border border-brand-dark/16 px-3 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/90"
|
||||
>
|
||||
허브로
|
||||
{copy.common.hub}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
@@ -35,13 +37,13 @@ export const SettingsPanelWidget = () => {
|
||||
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-brand-dark">Focus Preferences API</h2>
|
||||
<h2 className="text-base font-semibold text-brand-dark">{settings.focusPreferencesApi}</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">
|
||||
{isLoading
|
||||
? '저장된 설정을 불러오는 중이에요.'
|
||||
? settings.loading
|
||||
: isSaving
|
||||
? '변경 사항을 저장하는 중이에요.'
|
||||
: '변경 즉시 서버에 저장합니다.'}
|
||||
? settings.saving
|
||||
: settings.synced}
|
||||
</p>
|
||||
</div>
|
||||
{saveStateLabel ? (
|
||||
@@ -56,9 +58,9 @@ export const SettingsPanelWidget = () => {
|
||||
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-brand-dark">Reduce Motion</h2>
|
||||
<h2 className="text-base font-semibold text-brand-dark">{settings.reduceMotionTitle}</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">
|
||||
전환 애니메이션을 최소화합니다. (UI 토글 목업)
|
||||
{settings.reduceMotionDescription}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -88,8 +90,8 @@ export const SettingsPanelWidget = () => {
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
|
||||
<h2 className="text-base font-semibold text-brand-dark">알림 강도</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">집중 시작/종료 신호의 존재감을 선택합니다.</p>
|
||||
<h2 className="text-base font-semibold text-brand-dark">{settings.notificationIntensityTitle}</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">{settings.notificationIntensityDescription}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{NOTIFICATION_INTENSITY_OPTIONS.map((option) => (
|
||||
<button
|
||||
@@ -112,8 +114,8 @@ export const SettingsPanelWidget = () => {
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
|
||||
<h2 className="text-base font-semibold text-brand-dark">기본 프리셋</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">입장 시 자동 선택될 추천 세트를 고릅니다.</p>
|
||||
<h2 className="text-base font-semibold text-brand-dark">{settings.defaultPresetTitle}</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">{settings.defaultPresetDescription}</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{DEFAULT_PRESET_OPTIONS.map((preset) => (
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { FormEvent } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface GoalCompleteSheetProps {
|
||||
@@ -12,12 +13,7 @@ interface GoalCompleteSheetProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOAL_SUGGESTIONS = [
|
||||
'리뷰 코멘트 2개 처리',
|
||||
'문서 1문단 다듬기',
|
||||
'이슈 1개 정리',
|
||||
'메일 2개 회신',
|
||||
];
|
||||
const GOAL_SUGGESTIONS = copy.space.goalComplete.suggestions;
|
||||
|
||||
export const GoalCompleteSheet = ({
|
||||
open,
|
||||
@@ -53,10 +49,10 @@ export const GoalCompleteSheet = ({
|
||||
const trimmed = currentGoal.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return '다음 한 조각을 적어보세요';
|
||||
return copy.space.goalComplete.placeholderFallback;
|
||||
}
|
||||
|
||||
return `예: ${trimmed}`;
|
||||
return copy.space.goalComplete.placeholderExample(trimmed);
|
||||
}, [currentGoal]);
|
||||
|
||||
const canConfirm = draft.trim().length > 0;
|
||||
@@ -82,14 +78,14 @@ export const GoalCompleteSheet = ({
|
||||
<section className="pointer-events-auto w-[min(460px,94vw)] rounded-2xl border border-white/12 bg-black/26 px-3.5 py-3 text-white shadow-[0_14px_30px_rgba(2,6,23,0.28)] backdrop-blur-md">
|
||||
<header className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white/92">좋아요. 다음 한 조각은?</h3>
|
||||
<p className="mt-0.5 text-[11px] text-white/58">너무 크게 잡지 말고, 바로 다음 한 조각만.</p>
|
||||
<h3 className="text-sm font-semibold text-white/92">{copy.space.goalComplete.title}</h3>
|
||||
<p className="mt-0.5 text-[11px] text-white/58">{copy.space.goalComplete.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-[11px] text-white/72 transition-colors hover:bg-white/[0.12]"
|
||||
aria-label="닫기"
|
||||
aria-label={copy.space.goalComplete.closeAriaLabel}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -123,14 +119,14 @@ export const GoalCompleteSheet = ({
|
||||
onClick={onRest}
|
||||
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.goalComplete.restButton}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canConfirm}
|
||||
className="rounded-full border border-sky-200/42 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300 disabled:cursor-not-allowed disabled:border-white/16 disabled:bg-white/[0.08] disabled:text-white/48"
|
||||
>
|
||||
바로 다음 조각 시작
|
||||
{copy.space.goalComplete.confirmButton}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
||||
@@ -44,7 +45,7 @@ export const SpaceFocusHudWidget = ({
|
||||
const visibleRef = useRef(false);
|
||||
const playbackStateRef = useRef<'running' | 'paused'>(playbackState);
|
||||
const restReminderTimerRef = useRef<number | null>(null);
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '집중을 시작해요.';
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -58,7 +59,7 @@ export const SpaceFocusHudWidget = ({
|
||||
useEffect(() => {
|
||||
if (visible && !visibleRef.current && playbackState === 'running') {
|
||||
onStatusMessage({
|
||||
message: `이번 한 조각 · ${normalizedGoal}`,
|
||||
message: copy.space.focusHud.goalToast(normalizedGoal),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ export const SpaceFocusHudWidget = ({
|
||||
useEffect(() => {
|
||||
if (playbackStateRef.current === 'paused' && playbackState === 'running' && visible) {
|
||||
onStatusMessage({
|
||||
message: `이번 한 조각 · ${normalizedGoal}`,
|
||||
message: copy.space.focusHud.goalToast(normalizedGoal),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,7 +116,7 @@ export const SpaceFocusHudWidget = ({
|
||||
}
|
||||
|
||||
restReminderTimerRef.current = window.setTimeout(() => {
|
||||
onStatusMessage({ message: '5분이 지났어요. 다음 한 조각으로 돌아와요.' });
|
||||
onStatusMessage({ message: copy.space.focusHud.restReminder });
|
||||
restReminderTimerRef.current = null;
|
||||
}, 5 * 60 * 1000);
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||
import type { SceneAssetMap } from '@/entities/media';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { SceneSelectCarousel } from '@/features/scene-select';
|
||||
import { SessionGoalField } from '@/features/session-goal';
|
||||
import { Button } from '@/shared/ui';
|
||||
@@ -84,6 +85,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
onStart,
|
||||
resumeHint,
|
||||
}: SpaceSetupDrawerWidgetProps) => {
|
||||
const { setup } = copy.space;
|
||||
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -95,7 +97,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
return (
|
||||
soundPresets.find((preset) => preset.id === selectedSoundPresetId)?.label ??
|
||||
soundPresets[0]?.label ??
|
||||
'기본'
|
||||
copy.common.default
|
||||
);
|
||||
}, [selectedSoundPresetId, soundPresets]);
|
||||
|
||||
@@ -148,21 +150,21 @@ export const SpaceSetupDrawerWidget = ({
|
||||
return (
|
||||
<section
|
||||
className="fixed left-1/2 top-1/2 z-40 w-[min(428px,92vw)] -translate-x-1/2 -translate-y-1/2"
|
||||
aria-label="집중 시작 패널"
|
||||
aria-label={setup.panelAriaLabel}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="rounded-3xl border border-white/14 bg-[linear-gradient(160deg,rgba(15,23,42,0.68)_0%,rgba(8,13,27,0.56)_100%)] p-4 text-white shadow-[0_22px_52px_rgba(2,6,23,0.38)] backdrop-blur-2xl sm:p-5"
|
||||
>
|
||||
<header className="mb-3 space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-[0.18em] text-white/48">Ritual</p>
|
||||
<h1 className="text-[1.45rem] font-semibold leading-tight text-white">이번 한 조각을 정하고 시작해요.</h1>
|
||||
<p className="text-xs text-white/60">목표를 정한 뒤 HUD의 시작 버튼으로 실제 세션을 시작해요.</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.18em] text-white/48">{setup.eyebrow}</p>
|
||||
<h1 className="text-[1.45rem] font-semibold leading-tight text-white">{setup.title}</h1>
|
||||
<p className="text-xs text-white/60">{setup.description}</p>
|
||||
</header>
|
||||
|
||||
{resumeHint ? (
|
||||
<div className="mb-3 rounded-2xl border border-white/14 bg-black/22 px-3 py-2.5">
|
||||
<p className="text-[11px] text-white/62">지난 한 조각 이어서</p>
|
||||
<p className="text-[11px] text-white/62">{setup.resumeTitle}</p>
|
||||
<p className="mt-1 truncate text-sm text-white/88">{resumeHint.goal}</p>
|
||||
<div className="mt-2 flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
@@ -170,14 +172,14 @@ export const SpaceSetupDrawerWidget = ({
|
||||
onClick={resumeHint.onStartFresh}
|
||||
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/72 transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
새로 시작
|
||||
{setup.startFresh}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resumeHint.onResume}
|
||||
className="rounded-full border border-sky-200/34 bg-sky-200/14 px-2.5 py-1 text-[11px] text-white/90 transition-colors hover:bg-sky-200/22"
|
||||
>
|
||||
이어서 준비
|
||||
{setup.resumePrepare}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,19 +188,19 @@ export const SpaceSetupDrawerWidget = ({
|
||||
<div className="relative mb-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<SummaryChip
|
||||
label="배경"
|
||||
value={selectedScene?.name ?? '기본 배경'}
|
||||
label={setup.sceneLabel}
|
||||
value={selectedScene?.name ?? copy.common.defaultBackground}
|
||||
open={openPopover === 'space'}
|
||||
onClick={() => togglePopover('space')}
|
||||
/>
|
||||
<SummaryChip
|
||||
label="타이머"
|
||||
label={setup.timerLabel}
|
||||
value={selectedTimerLabel}
|
||||
open={openPopover === 'timer'}
|
||||
onClick={() => togglePopover('timer')}
|
||||
/>
|
||||
<SummaryChip
|
||||
label="사운드"
|
||||
label={setup.soundLabel}
|
||||
value={selectedSoundLabel}
|
||||
open={openPopover === 'sound'}
|
||||
onClick={() => togglePopover('sound')}
|
||||
@@ -289,7 +291,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
{!canStart ? <p className="text-[10px] text-white/56">목표를 적으면 시작할 수 있어요.</p> : null}
|
||||
{!canStart ? <p className="text-[10px] text-white/56">{setup.readyHint}</p> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
form="space-setup-ritual-form"
|
||||
@@ -299,7 +301,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
'h-10 rounded-xl !bg-sky-300/84 !text-slate-900 shadow-[0_8px_16px_rgba(125,211,252,0.24)] hover:!bg-sky-300 disabled:!bg-white/10 disabled:!text-white/42',
|
||||
)}
|
||||
>
|
||||
집중 화면 열기
|
||||
{setup.openFocusScreen}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||
|
||||
@@ -100,7 +101,7 @@ export const SpaceSideSheet = ({
|
||||
{dismissible ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="시트 닫기"
|
||||
aria-label={copy.modal.closeAriaLabel}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-slate-950/14 backdrop-blur-[1px] transition-opacity',
|
||||
@@ -147,7 +148,7 @@ export const SpaceSideSheet = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
aria-label={copy.modal.closeButton}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/14 bg-white/6 text-[12px] text-white/72 transition-colors hover:bg-white/12 hover:text-white"
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import {
|
||||
RECOVERY_30S_MODE_LABEL,
|
||||
@@ -26,11 +27,7 @@ interface SpaceTimerHudWidgetProps {
|
||||
onGoalCompleteRequest?: () => void;
|
||||
}
|
||||
|
||||
const HUD_ACTIONS = [
|
||||
{ id: 'start', label: '시작', icon: '▶' },
|
||||
{ id: 'pause', label: '일시정지', icon: '⏸' },
|
||||
{ id: 'reset', label: '리셋', icon: '↺' },
|
||||
] as const;
|
||||
const HUD_ACTIONS = copy.space.timerHud.actions;
|
||||
|
||||
export const SpaceTimerHudWidget = ({
|
||||
timerLabel,
|
||||
@@ -51,14 +48,14 @@ export const SpaceTimerHudWidget = ({
|
||||
onGoalCompleteRequest,
|
||||
}: SpaceTimerHudWidgetProps) => {
|
||||
const { isBreatheMode, triggerRestart } = useRestart30s();
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.timerHud.goalFallback;
|
||||
const modeLabel = isBreatheMode
|
||||
? RECOVERY_30S_MODE_LABEL
|
||||
: !hasActiveSession
|
||||
? 'Ready'
|
||||
? copy.space.timerHud.readyMode
|
||||
: sessionPhase === 'break'
|
||||
? 'Break'
|
||||
: 'Focus';
|
||||
? copy.space.timerHud.breakMode
|
||||
: copy.space.timerHud.focusMode;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -105,7 +102,7 @@ export const SpaceTimerHudWidget = ({
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 items-center gap-2">
|
||||
<p className={cn('min-w-0 truncate text-sm', isImmersionMode ? 'text-white/88' : 'text-white/86')}>
|
||||
<span className="text-white/62">이번 한 조각 · </span>
|
||||
<span className="text-white/62">{copy.space.timerHud.goalPrefix}</span>
|
||||
<span className="text-white/90">{normalizedGoal}</span>
|
||||
</p>
|
||||
<button
|
||||
@@ -113,7 +110,7 @@ export const SpaceTimerHudWidget = ({
|
||||
onClick={onGoalCompleteRequest}
|
||||
className="shrink-0 rounded-full border border-white/16 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white/86"
|
||||
>
|
||||
완료
|
||||
{copy.space.timerHud.completeButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { formatThoughtCount, RAIL_ICON } from './constants';
|
||||
|
||||
interface FocusRightRailProps {
|
||||
@@ -25,8 +26,8 @@ export const FocusRightRail = ({
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="인박스 열기"
|
||||
title="인박스"
|
||||
aria-label={copy.space.inbox.openInboxAriaLabel}
|
||||
title={copy.space.inbox.openInboxTitle}
|
||||
onClick={onOpenInbox}
|
||||
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||
>
|
||||
@@ -39,8 +40,8 @@ export const FocusRightRail = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Quick Controls 열기"
|
||||
title="Quick Controls"
|
||||
aria-label={copy.space.rightRail.openQuickControlsAriaLabel}
|
||||
title={copy.space.rightRail.openQuickControlsTitle}
|
||||
onClick={onOpenControlCenter}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SceneAssetMap } from '@/entities/media';
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { ExitHoldButton } from '@/features/exit-hold';
|
||||
import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { PlanPill } from '@/features/plan-pill';
|
||||
@@ -75,6 +76,7 @@ export const SpaceToolsDockWidget = ({
|
||||
onStatusMessage,
|
||||
onExitRequested,
|
||||
}: SpaceToolsDockWidgetProps) => {
|
||||
const { toolsDock, controlCenter } = copy.space;
|
||||
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
|
||||
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
|
||||
const [autoHideControls, setAutoHideControls] = useState(true);
|
||||
@@ -86,7 +88,7 @@ export const SpaceToolsDockWidget = ({
|
||||
|
||||
const selectedSoundLabel = useMemo(() => {
|
||||
return (
|
||||
SOUND_PRESETS.find((preset) => preset.id === selectedPresetId)?.label ?? SOUND_PRESETS[0]?.label ?? '기본'
|
||||
SOUND_PRESETS.find((preset) => preset.id === selectedPresetId)?.label ?? SOUND_PRESETS[0]?.label ?? copy.common.default
|
||||
);
|
||||
}, [selectedPresetId]);
|
||||
|
||||
@@ -233,11 +235,11 @@ export const SpaceToolsDockWidget = ({
|
||||
|
||||
setNoteDraft('');
|
||||
onStatusMessage({
|
||||
message: '인박스에 저장됨',
|
||||
message: toolsDock.inboxSaved,
|
||||
durationMs: 4200,
|
||||
priority: 'undo',
|
||||
action: {
|
||||
label: '실행취소',
|
||||
label: toolsDock.undo,
|
||||
onClick: () => {
|
||||
const removed = onDeleteThought(addedThought.id);
|
||||
|
||||
@@ -245,7 +247,7 @@ export const SpaceToolsDockWidget = ({
|
||||
return;
|
||||
}
|
||||
|
||||
onStatusMessage({ message: '저장 취소됨' });
|
||||
onStatusMessage({ message: toolsDock.inboxSaveUndone });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -263,14 +265,14 @@ export const SpaceToolsDockWidget = ({
|
||||
}
|
||||
|
||||
onStatusMessage({
|
||||
message: '삭제됨',
|
||||
message: toolsDock.deleted,
|
||||
durationMs: 4200,
|
||||
priority: 'undo',
|
||||
action: {
|
||||
label: '실행취소',
|
||||
label: toolsDock.undo,
|
||||
onClick: () => {
|
||||
onRestoreThought(removedThought);
|
||||
onStatusMessage({ message: '삭제를 취소했어요.' });
|
||||
onStatusMessage({ message: toolsDock.deleteUndone });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -280,19 +282,19 @@ export const SpaceToolsDockWidget = ({
|
||||
const snapshot = onClearInbox();
|
||||
|
||||
if (snapshot.length === 0) {
|
||||
onStatusMessage({ message: '비울 항목이 없어요.' });
|
||||
onStatusMessage({ message: toolsDock.emptyToClear });
|
||||
return;
|
||||
}
|
||||
|
||||
onStatusMessage({
|
||||
message: '모두 비워짐',
|
||||
message: toolsDock.clearedAll,
|
||||
durationMs: 4200,
|
||||
priority: 'undo',
|
||||
action: {
|
||||
label: '실행취소',
|
||||
label: toolsDock.undo,
|
||||
onClick: () => {
|
||||
onRestoreThoughts(snapshot);
|
||||
onStatusMessage({ message: '복원했어요.' });
|
||||
onStatusMessage({ message: toolsDock.restored });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -304,28 +306,28 @@ export const SpaceToolsDockWidget = ({
|
||||
return;
|
||||
}
|
||||
|
||||
onStatusMessage({ message: 'NORMAL 플랜 사용 중 · 잠금 항목에서만 업그레이드할 수 있어요.' });
|
||||
onStatusMessage({ message: toolsDock.normalPlanInfo });
|
||||
};
|
||||
|
||||
const handleLockedClick = (source: string) => {
|
||||
onStatusMessage({ message: `${source}은(는) PRO 기능이에요.` });
|
||||
onStatusMessage({ message: toolsDock.proFeatureLocked(source) });
|
||||
openUtilityPanel('paywall');
|
||||
};
|
||||
|
||||
const handleSelectProFeature = (featureId: string) => {
|
||||
const label =
|
||||
featureId === 'scene-packs'
|
||||
? 'Scene Packs'
|
||||
? toolsDock.featureLabels.scenePacks
|
||||
: featureId === 'sound-packs'
|
||||
? 'Sound Packs'
|
||||
: 'Profiles';
|
||||
? toolsDock.featureLabels.soundPacks
|
||||
: toolsDock.featureLabels.profiles;
|
||||
|
||||
onStatusMessage({ message: `${label} 준비 중(더미)` });
|
||||
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
|
||||
};
|
||||
|
||||
const handleStartPro = () => {
|
||||
setPlan('pro');
|
||||
onStatusMessage({ message: '결제(더미)' });
|
||||
onStatusMessage({ message: toolsDock.purchaseMock });
|
||||
openUtilityPanel('control-center');
|
||||
};
|
||||
|
||||
@@ -370,7 +372,7 @@ export const SpaceToolsDockWidget = ({
|
||||
{isFocusMode && openPopover ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="팝오버 닫기"
|
||||
aria-label={toolsDock.popoverCloseAria}
|
||||
onClick={() => setOpenPopover(null)}
|
||||
className="fixed inset-0 z-30"
|
||||
/>
|
||||
@@ -417,7 +419,7 @@ export const SpaceToolsDockWidget = ({
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
|
||||
>
|
||||
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
|
||||
<span>Notes {formatThoughtCount(thoughtCount)}</span>
|
||||
<span>{toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
|
||||
<span aria-hidden className="text-white/60">▾</span>
|
||||
</button>
|
||||
|
||||
@@ -485,7 +487,7 @@ export const SpaceToolsDockWidget = ({
|
||||
<SpaceSideSheet
|
||||
open={isFocusMode && utilityPanel !== null}
|
||||
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
|
||||
subtitle={utilityPanel === 'control-center' ? '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.' : undefined}
|
||||
subtitle={utilityPanel === 'control-center' ? controlCenter.sideSheetSubtitle : undefined}
|
||||
headerAction={
|
||||
utilityPanel === 'control-center' ? (
|
||||
<PlanPill plan={plan} onClick={handlePlanPillClick} />
|
||||
@@ -539,8 +541,8 @@ export const SpaceToolsDockWidget = ({
|
||||
{utilityPanel === 'manage-plan' ? (
|
||||
<ManagePlanSheetContent
|
||||
onClose={() => setUtilityPanel(null)}
|
||||
onManage={() => onStatusMessage({ message: '구독 관리(더미)' })}
|
||||
onRestore={() => onStatusMessage({ message: '구매 복원(더미)' })}
|
||||
onManage={() => onStatusMessage({ message: toolsDock.manageSubscriptionMock })}
|
||||
onRestore={() => onStatusMessage({ message: toolsDock.restorePurchaseMock })}
|
||||
/>
|
||||
) : null}
|
||||
</SpaceSideSheet>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SpaceUtilityPanelId } from '../model/types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const ANCHOR_ICON = {
|
||||
sound: (
|
||||
@@ -66,10 +67,10 @@ export const RAIL_ICON = {
|
||||
};
|
||||
|
||||
export const UTILITY_PANEL_TITLE: Record<SpaceUtilityPanelId, string> = {
|
||||
'control-center': 'Quick Controls',
|
||||
inbox: '인박스',
|
||||
paywall: 'PRO',
|
||||
'manage-plan': '플랜 관리',
|
||||
'control-center': copy.space.toolsDock.utilityPanelTitle['control-center'],
|
||||
inbox: copy.space.toolsDock.utilityPanelTitle.inbox,
|
||||
paywall: copy.space.toolsDock.utilityPanelTitle.paywall,
|
||||
'manage-plan': copy.space.toolsDock.utilityPanelTitle['manage-plan'],
|
||||
};
|
||||
|
||||
export const formatThoughtCount = (count: number) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { RecentThought } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { InboxList } from '@/features/inbox';
|
||||
|
||||
interface InboxToolPanelProps {
|
||||
@@ -20,13 +21,13 @@ export const InboxToolPanel = ({
|
||||
return (
|
||||
<div className="relative space-y-3.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-white/58">나중에 모아보는 읽기 전용 인박스</p>
|
||||
<p className="text-xs text-white/58">{copy.space.inbox.readOnly}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
|
||||
>
|
||||
모두 비우기
|
||||
{copy.space.inbox.clearAll}
|
||||
</button>
|
||||
</div>
|
||||
<InboxList
|
||||
@@ -38,15 +39,15 @@ export const InboxToolPanel = ({
|
||||
{confirmOpen ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-2xl bg-slate-950/70 px-3 backdrop-blur-sm">
|
||||
<div className="w-full max-w-[272px] rounded-2xl border border-white/14 bg-slate-900/92 p-3.5 shadow-xl shadow-slate-950/45">
|
||||
<p className="text-sm font-medium text-white/92">정말 인박스를 비울까요?</p>
|
||||
<p className="mt-1 text-[11px] text-white/60">실수라면 토스트에서 실행취소할 수 있어요.</p>
|
||||
<p className="text-sm font-medium text-white/92">{copy.space.inbox.clearConfirmTitle}</p>
|
||||
<p className="mt-1 text-[11px] text-white/60">{copy.space.inbox.clearConfirmDescription}</p>
|
||||
<div className="mt-3 flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
|
||||
>
|
||||
취소
|
||||
{copy.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -56,7 +57,7 @@ export const InboxToolPanel = ({
|
||||
}}
|
||||
className="rounded-full border border-rose-200/34 bg-rose-200/16 px-2.5 py-1 text-[11px] text-rose-100/92 transition-colors hover:bg-rose-200/24"
|
||||
>
|
||||
비우기
|
||||
{copy.space.inbox.clearButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import type { TimerPreset } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { DEFAULT_PRESET_OPTIONS } from '@/shared/config/settingsOptions';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
@@ -23,6 +24,7 @@ export const SettingsToolPanel = ({
|
||||
onSelectScene,
|
||||
onSelectTimer,
|
||||
}: SettingsToolPanelProps) => {
|
||||
const { settingsPanel } = copy.space;
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
const [defaultPresetId, setDefaultPresetId] = useState<
|
||||
(typeof DEFAULT_PRESET_OPTIONS)[number]['id']
|
||||
@@ -33,8 +35,8 @@ export const SettingsToolPanel = ({
|
||||
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Reduce Motion</p>
|
||||
<p className="mt-1 text-xs text-white/58">화면 전환을 조금 더 차분하게 표시합니다.</p>
|
||||
<p className="text-sm font-medium text-white">{settingsPanel.reduceMotion}</p>
|
||||
<p className="mt-1 text-xs text-white/58">{settingsPanel.reduceMotionDescription}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -59,8 +61,8 @@ export const SettingsToolPanel = ({
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
||||
<p className="text-sm font-medium text-white">배경</p>
|
||||
<p className="mt-1 text-xs text-white/58">몰입 중에도 배경 scene을 바꿀 수 있어요.</p>
|
||||
<p className="text-sm font-medium text-white">{settingsPanel.background}</p>
|
||||
<p className="mt-1 text-xs text-white/58">{settingsPanel.backgroundDescription}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{scenes.slice(0, 4).map((scene) => {
|
||||
const selected = scene.id === selectedSceneId;
|
||||
@@ -85,8 +87,8 @@ export const SettingsToolPanel = ({
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
||||
<p className="text-sm font-medium text-white">타이머 프리셋</p>
|
||||
<p className="mt-1 text-xs text-white/58">기본 프리셋만 빠르게 고를 수 있어요.</p>
|
||||
<p className="text-sm font-medium text-white">{settingsPanel.timerPreset}</p>
|
||||
<p className="mt-1 text-xs text-white/58">{settingsPanel.timerPresetDescription}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{timerPresets.slice(0, 3).map((preset) => {
|
||||
const selected = preset.label === selectedTimerLabel;
|
||||
@@ -111,7 +113,7 @@ export const SettingsToolPanel = ({
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
||||
<p className="text-sm font-medium text-white">기본 프리셋</p>
|
||||
<p className="text-sm font-medium text-white">{settingsPanel.defaultPreset}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{DEFAULT_PRESET_OPTIONS.map((preset) => (
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const StatsToolPanel = () => {
|
||||
const previewStats = [TODAY_STATS[0], TODAY_STATS[1], WEEKLY_STATS[0], WEEKLY_STATS[2]];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-white/58">오늘 흐름과 최근 7일 리듬을 가볍게 확인하세요.</p>
|
||||
<p className="text-xs text-white/58">{copy.space.statsPanel.description}</p>
|
||||
|
||||
<section className="grid gap-2.5 sm:grid-cols-2">
|
||||
{previewStats.map((stat) => (
|
||||
@@ -19,7 +20,7 @@ export const StatsToolPanel = () => {
|
||||
|
||||
<section className="rounded-2xl border border-white/14 bg-white/6 p-3.5">
|
||||
<div className="h-28 rounded-xl border border-dashed border-white/20 bg-[linear-gradient(180deg,rgba(148,163,184,0.14),rgba(148,163,184,0.02))]" />
|
||||
<p className="mt-2 text-[11px] text-white/54">그래프 플레이스홀더</p>
|
||||
<p className="mt-2 text-[11px] text-white/54">{copy.space.statsPanel.graphPlaceholder}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface QuickNotesPopoverProps {
|
||||
noteDraft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
@@ -16,7 +18,7 @@ export const QuickNotesPopover = ({
|
||||
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
|
||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
|
||||
>
|
||||
<p className="text-[11px] text-white/56">떠오른 생각을 잠깐 주차해요</p>
|
||||
<p className="text-[11px] text-white/56">{copy.space.quickNotes.title}</p>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<input
|
||||
value={noteDraft}
|
||||
@@ -29,7 +31,7 @@ export const QuickNotesPopover = ({
|
||||
event.preventDefault();
|
||||
onDraftEnter();
|
||||
}}
|
||||
placeholder="떠오른 생각을 잠깐 주차…"
|
||||
placeholder={copy.space.quickNotes.placeholder}
|
||||
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
@@ -37,10 +39,10 @@ export const QuickNotesPopover = ({
|
||||
onClick={onSubmit}
|
||||
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
|
||||
>
|
||||
저장
|
||||
{copy.space.quickNotes.submit}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-white/52">나중에 인박스에서 정리해요.</p>
|
||||
<p className="mt-2 text-[11px] text-white/52">{copy.space.quickNotes.hint}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import type { SoundPreset } from '@/entities/session';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface QuickSoundPopoverProps {
|
||||
selectedSoundLabel: string;
|
||||
@@ -32,14 +33,14 @@ export const QuickSoundPopover = ({
|
||||
className="mb-2 w-[min(288px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
|
||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
|
||||
>
|
||||
<p className="text-[11px] text-white/56">현재 사운드</p>
|
||||
<p className="text-[11px] text-white/56">{copy.space.quickSound.currentSound}</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-white/88">{selectedSoundLabel}</p>
|
||||
|
||||
<div className="mt-3 rounded-xl border border-white/14 bg-white/[0.04] px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isSoundMuted ? '음소거 해제' : '음소거'}
|
||||
aria-label={isSoundMuted ? copy.space.quickSound.unmuteAriaLabel : copy.space.quickSound.muteAriaLabel}
|
||||
onClick={onToggleMute}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-xs text-white/80 transition-colors hover:bg-white/[0.12]"
|
||||
>
|
||||
@@ -53,7 +54,7 @@ export const QuickSoundPopover = ({
|
||||
value={soundVolume}
|
||||
onChange={(event) => onVolumeChange(Number(event.target.value))}
|
||||
onKeyDown={onVolumeKeyDown}
|
||||
aria-label="사운드 볼륨"
|
||||
aria-label={copy.space.quickSound.volumeAriaLabel}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/18 accent-sky-200"
|
||||
/>
|
||||
<span className="w-9 text-right text-[11px] text-white/66">
|
||||
@@ -62,7 +63,7 @@ export const QuickSoundPopover = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] text-white/56">빠른 전환</p>
|
||||
<p className="mt-3 text-[11px] text-white/56">{copy.space.quickSound.quickSwitch}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{quickSoundPresets.map((preset) => {
|
||||
const selected = preset.id === selectedPresetId;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@/entities/session';
|
||||
import { useFocusSessionEngine } from '@/features/focus-session';
|
||||
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
||||
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
||||
@@ -436,7 +437,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
setPendingSessionEntryPoint(entryPoint);
|
||||
setPreviewPlaybackState('paused');
|
||||
setWorkspaceMode('focus');
|
||||
queuedFocusStatusMessageRef.current = '준비 완료 · 시작 버튼을 눌러 집중을 시작해요.';
|
||||
queuedFocusStatusMessageRef.current = copy.space.workspace.readyToStart;
|
||||
};
|
||||
|
||||
const startFocusFlow = async () => {
|
||||
@@ -462,7 +463,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
setPreviewPlaybackState('paused');
|
||||
pushStatusLine({
|
||||
message: '세션을 시작하지 못했어요. 잠시 후 다시 시도해 주세요.',
|
||||
message: copy.space.workspace.startFailed,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -488,7 +489,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
if (!resumedSession) {
|
||||
pushStatusLine({
|
||||
message: '세션을 다시 시작하지 못했어요.',
|
||||
message: copy.space.workspace.resumeFailed,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -498,7 +499,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
if (!didAbandon) {
|
||||
pushStatusLine({
|
||||
message: '세션 종료를 완료하지 못했어요.',
|
||||
message: copy.space.workspace.abandonFailed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -518,7 +519,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
if (!pausedSession) {
|
||||
pushStatusLine({
|
||||
message: '세션을 일시정지하지 못했어요.',
|
||||
message: copy.space.workspace.pauseFailed,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -532,13 +533,13 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
if (!restartedSession) {
|
||||
pushStatusLine({
|
||||
message: '현재 페이즈를 다시 시작하지 못했어요.',
|
||||
message: copy.space.workspace.restartFailed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pushStatusLine({
|
||||
message: '현재 페이즈를 처음부터 다시 시작했어요.',
|
||||
message: copy.space.workspace.restarted,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -557,7 +558,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
if (!completedSession) {
|
||||
pushStatusLine({
|
||||
message: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
||||
message: copy.space.workspace.goalCompleteSyncFailed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -568,7 +569,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
setPendingSessionEntryPoint('goal-complete');
|
||||
setPreviewPlaybackState('paused');
|
||||
pushStatusLine({
|
||||
message: '다음 한 조각 준비 완료 · 시작 버튼을 눌러 이어가요.',
|
||||
message: copy.space.workspace.nextGoalReady,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useFocusStats } from '@/features/stats';
|
||||
|
||||
const StatSection = ({
|
||||
@@ -42,45 +43,46 @@ const formatMinutes = (minutes: number) => {
|
||||
};
|
||||
|
||||
export const StatsOverviewWidget = () => {
|
||||
const { stats } = copy;
|
||||
const { summary, isLoading, error, source, refetch } = useFocusStats();
|
||||
const todayItems = [
|
||||
{
|
||||
id: 'today-focus',
|
||||
label: '오늘 집중 시간',
|
||||
label: stats.todayFocus,
|
||||
value: formatMinutes(summary.today.focusMinutes),
|
||||
delta: source === 'api' ? 'API' : 'Mock',
|
||||
delta: source === 'api' ? stats.apiLabel : stats.mockLabel,
|
||||
},
|
||||
{
|
||||
id: 'today-cycles',
|
||||
label: '완료한 사이클',
|
||||
value: `${summary.today.completedCycles}회`,
|
||||
label: stats.completedCycles,
|
||||
value: `${summary.today.completedCycles}${stats.countUnit}`,
|
||||
delta: `${summary.today.completedCycles > 0 ? '+' : ''}${summary.today.completedCycles}`,
|
||||
},
|
||||
{
|
||||
id: 'today-entry',
|
||||
label: '입장 횟수',
|
||||
value: `${summary.today.sessionEntries}회`,
|
||||
delta: source === 'api' ? '동기화됨' : '임시값',
|
||||
label: stats.sessionEntries,
|
||||
value: `${summary.today.sessionEntries}${stats.countUnit}`,
|
||||
delta: source === 'api' ? stats.syncedApi : stats.temporary,
|
||||
},
|
||||
];
|
||||
const weeklyItems = [
|
||||
{
|
||||
id: 'week-focus',
|
||||
label: '최근 7일 집중 시간',
|
||||
label: stats.last7DaysFocus,
|
||||
value: formatMinutes(summary.last7Days.focusMinutes),
|
||||
delta: source === 'api' ? '실집계' : '목업',
|
||||
delta: source === 'api' ? stats.actualAggregate : stats.mockAggregate,
|
||||
},
|
||||
{
|
||||
id: 'week-best-day',
|
||||
label: '최고 몰입일',
|
||||
label: stats.bestDay,
|
||||
value: summary.last7Days.bestDayLabel,
|
||||
delta: formatMinutes(summary.last7Days.bestDayFocusMinutes),
|
||||
},
|
||||
{
|
||||
id: 'week-consistency',
|
||||
label: '연속 달성',
|
||||
value: `${summary.last7Days.streakDays}일`,
|
||||
delta: summary.last7Days.streakDays > 0 ? '유지 중' : '시작 전',
|
||||
label: stats.streak,
|
||||
value: `${summary.last7Days.streakDays}${stats.dayUnit}`,
|
||||
delta: summary.last7Days.streakDays > 0 ? stats.streakActive : stats.streakStart,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -88,12 +90,12 @@ export const StatsOverviewWidget = () => {
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_18%_0%,rgba(167,204,237,0.45),transparent_50%),radial-gradient(circle_at_88%_8%,rgba(191,219,254,0.4),transparent_42%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_52%,#e9f1fa_100%)] text-brand-dark">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between rounded-xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
|
||||
<h1 className="text-xl font-semibold">Stats</h1>
|
||||
<h1 className="text-xl font-semibold">{stats.title}</h1>
|
||||
<Link
|
||||
href="/app"
|
||||
className="rounded-lg border border-brand-dark/16 px-3 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/90"
|
||||
>
|
||||
허브로
|
||||
{copy.common.hub}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
@@ -102,13 +104,13 @@ export const StatsOverviewWidget = () => {
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-brand-dark/72">
|
||||
{source === 'api' ? 'API 통계 사용 중' : 'API 실패로 mock 통계 표시 중'}
|
||||
{source === 'api' ? stats.sourceApi : stats.sourceMock}
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="mt-1 text-xs text-rose-500">{error}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-brand-dark/56">
|
||||
{isLoading ? '통계를 불러오는 중이에요.' : '화면 진입 시 최신 요약을 동기화합니다.'}
|
||||
{isLoading ? stats.loading : stats.synced}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -119,16 +121,16 @@ export const StatsOverviewWidget = () => {
|
||||
}}
|
||||
className="rounded-lg border border-brand-dark/16 px-3 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/90"
|
||||
>
|
||||
새로고침
|
||||
{stats.refresh}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StatSection title="오늘" items={todayItems} />
|
||||
<StatSection title="최근 7일" items={weeklyItems} />
|
||||
<StatSection title={stats.today} items={todayItems} />
|
||||
<StatSection title={stats.last7Days} items={weeklyItems} />
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">집중 흐름 그래프</h2>
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{stats.chartTitle}</h2>
|
||||
<div className="rounded-xl border border-dashed border-brand-dark/18 bg-white/65 p-5">
|
||||
<div className="h-52 rounded-lg border border-brand-dark/12 bg-[linear-gradient(180deg,rgba(148,163,184,0.15),rgba(148,163,184,0.04))] p-4">
|
||||
{summary.trend.length > 0 ? (
|
||||
@@ -141,7 +143,7 @@ export const StatsOverviewWidget = () => {
|
||||
<div
|
||||
className="w-full rounded-md bg-brand-primary/55"
|
||||
style={{ height: `${barHeight}%` }}
|
||||
title={`${point.date} · ${point.focusMinutes}분`}
|
||||
title={stats.barTitle(point.date, point.focusMinutes)}
|
||||
/>
|
||||
<span className="text-[10px] text-brand-dark/56">
|
||||
{point.date.slice(5)}
|
||||
@@ -154,8 +156,8 @@ export const StatsOverviewWidget = () => {
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-brand-dark/56">
|
||||
{summary.trend.length > 0
|
||||
? 'trend 응답으로 간단한 막대 그래프를 렌더링합니다.'
|
||||
: 'trend 응답이 비어 있어 플레이스홀더 상태입니다.'}
|
||||
? stats.chartWithTrend
|
||||
: stats.chartWithoutTrend}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user