feat(space/app): app 진입부 및 space 몰입 환경(HUD/Tools) 프리미엄 UI 리팩토링

맥락:
- 기존 app 대시보드와 space 화면의 UI가 SaaS 툴처럼 딱딱하고 투박하여, 유저가 기꺼이 지갑을 열 만한 몰입감과 고급스러움(Premium feel)이 부족함.
- 인지적 과부하를 줄이기 위해 제안된 '첫 5분 행동(Micro-step)'이 타이머 영역에 묻혀 있어 행동 유발 효과가 미미함.

변경사항:
- app: 컨테이너 박스를 제거하고 전체 배경 화면(Immersive Background)과 Glassmorphism을 활용한 1.5 Step 진입 플로우로 전면 개편.
- space/hud: 하단의 두꺼운 타이머 패널을 초박형(Slim) 글라스 알약 형태로 축소하여 배경 씬의 개방감 확보.
- space/hud: 목표(Goal)와 첫 단계(Micro-step)를 분리하여 좌측 상단의 우아한 Floating UI로 재배치하고, 체크 완료 시 사라지는 도파민 인터랙션 추가.
- space/tools: 흩어져 있던 노트, 사운드, 설정 도구들을 우측 레일(Right-Rail)로 통합하고 팝오버 디자인을 고급화함.
- ui/contrast: 밝은 배경에서도 텍스트가 잘 보이도록 좌측 상단 비네팅(Vignette) 및 다중 텍스트 그림자(Multi-layered Shadow) 효과 적용.

검증:
- npm run build 정상 통과 확인.
- 브라우저 상에서 micro-step 완료 애니메이션 및 도구막대 팝오버 슬라이드 동작 확인.

세션-상태: app 진입부터 space 몰입까지의 코어 UX/UI 하이엔드 개편 완료.
세션-다음: 프로 요금제(PRO) 전환 유도(Paywall) 흐름 및 상세 분석 리포트(Analytics) 뷰 구현.
세션-리스크: 없음.
This commit is contained in:
2026-03-13 14:57:35 +09:00
parent 2506dd53a7
commit abdde2a8ae
36 changed files with 2120 additions and 923 deletions

View File

@@ -1,18 +1,33 @@
'use client';
import { useAdminConsole } from '../model/useAdminConsole';
import { AdminDashboardView } from './AdminDashboardView';
import { AdminLoginView } from './AdminLoginView';
export const AdminConsoleWidget = () => {
const {
session,
activeView,
setActiveView,
activeMeta,
isDurationOverrideEnabled,
setIsDurationOverrideEnabled,
loginId,
password,
loginError,
loginPending,
scenePending,
soundPending,
currentMessage,
uploadResult,
resultSummary,
lastExtractedDurationSec,
setLoginId,
setPassword,
handleLogin,
handleLogout,
handleSceneUpload,
handleSoundUpload,
} = useAdminConsole();
if (!session) {
@@ -30,12 +45,22 @@ export const AdminConsoleWidget = () => {
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#f3f4f8] text-slate-900">
<div className="text-center">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="mt-2 text-slate-500">Welcome, {session.user?.name}!</p>
<p className="mt-4 text-sm text-slate-400">Dashboard is under construction.</p>
</div>
</div>
<AdminDashboardView
session={session}
activeView={activeView}
onActiveViewChange={setActiveView}
activeMeta={activeMeta}
isDurationOverrideEnabled={isDurationOverrideEnabled}
onDurationOverrideChange={setIsDurationOverrideEnabled}
currentMessage={currentMessage}
uploadResult={uploadResult}
resultSummary={resultSummary}
lastExtractedDurationSec={lastExtractedDurationSec}
scenePending={scenePending}
soundPending={soundPending}
onLogout={handleLogout}
onSceneSubmit={handleSceneUpload}
onSoundSubmit={handleSoundUpload}
/>
);
};

View File

@@ -0,0 +1,482 @@
import type { FormEventHandler } from 'react';
import type { AuthResponse } from '@/entities/auth';
import { copy } from '@/shared/i18n';
import { Button } from '@/shared/ui';
import type { AdminView, UploadResult } from '../model/types';
import { fieldClassName, fileClassName, navItems, textareaClassName } from './constants';
interface AdminDashboardViewProps {
session: AuthResponse;
activeView: AdminView;
onActiveViewChange: (view: AdminView) => void;
activeMeta: typeof copy.admin.views.scene | typeof copy.admin.views.sound;
isDurationOverrideEnabled: boolean;
onDurationOverrideChange: (next: boolean) => void;
currentMessage: string | null;
uploadResult: UploadResult | null;
resultSummary: string;
lastExtractedDurationSec: number | null;
scenePending: boolean;
soundPending: boolean;
onLogout: () => void;
onSceneSubmit: FormEventHandler<HTMLFormElement>;
onSoundSubmit: FormEventHandler<HTMLFormElement>;
}
const MessagePanel = ({ message }: { message: string | null }) => {
if (!message) {
return null;
}
return (
<div className="mt-5 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
{message}
</div>
);
};
const AdminSceneForm = ({
currentMessage,
scenePending,
onSubmit,
}: {
currentMessage: string | null;
scenePending: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
}) => {
return (
<form onSubmit={onSubmit} 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">{copy.admin.views.scene.workspaceTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.admin.views.scene.workspaceDescription}</p>
</div>
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="px-6 py-6">
<div className="max-w-xl">
<div>
<label htmlFor="scene-id" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.scene.sceneIdLabel}
</label>
<input
id="scene-id"
name="sceneId"
className={fieldClassName}
placeholder={copy.admin.views.scene.sceneIdPlaceholder}
/>
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.sceneIdHint}</p>
</div>
</div>
<div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-5">
<div className="max-w-2xl">
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.scene.sourceImageLabel}
</label>
<input
name="sourceImageFile"
type="file"
accept="image/jpeg,image/png"
className={fileClassName}
/>
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.sourceImageHint}</p>
<p className="mt-1 text-xs text-slate-400">{copy.admin.views.scene.sourceImageDerivedHint}</p>
</div>
</div>
<div className="mt-6 max-w-2xl">
<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={copy.admin.views.scene.blurDataUrlPlaceholder}
/>
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.blurDataUrlHint}</p>
</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">{copy.admin.views.scene.notesTitle}</p>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
{copy.admin.views.scene.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
<MessagePanel message={currentMessage} />
<Button
type="submit"
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
disabled={scenePending}
>
{scenePending ? copy.admin.views.scene.pending : copy.admin.views.scene.submit}
</Button>
</div>
</div>
</form>
);
};
const AdminSoundForm = ({
currentMessage,
isDurationOverrideEnabled,
lastExtractedDurationSec,
onDurationOverrideChange,
onSubmit,
soundPending,
}: {
currentMessage: string | null;
isDurationOverrideEnabled: boolean;
lastExtractedDurationSec: number | null;
onDurationOverrideChange: (next: boolean) => void;
onSubmit: FormEventHandler<HTMLFormElement>;
soundPending: boolean;
}) => {
return (
<form onSubmit={onSubmit} 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">{copy.admin.views.sound.workspaceTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.admin.views.sound.workspaceDescription}</p>
</div>
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="px-6 py-6">
<div className="grid gap-5 md:grid-cols-3">
<div className="md:col-span-2">
<label htmlFor="preset-id" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.presetIdLabel}
</label>
<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">
{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">
{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">
{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">
{copy.admin.views.sound.fallbackLoopFileLabel}
</label>
<input name="fallbackLoopFile" type="file" accept="audio/*" className={fileClassName} />
</div>
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-900">
{copy.admin.views.sound.durationOverrideToggle}
</p>
<p className="mt-1 text-xs leading-5 text-slate-500">
{copy.admin.views.sound.durationOverrideHint}
</p>
</div>
<button
type="button"
onClick={() => onDurationOverrideChange(!isDurationOverrideEnabled)}
className={`inline-flex h-7 w-12 items-center rounded-full px-1 transition ${
isDurationOverrideEnabled ? 'bg-slate-900' : 'bg-slate-300'
}`}
aria-pressed={isDurationOverrideEnabled}
>
<span
className={`h-5 w-5 rounded-full bg-white transition ${
isDurationOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{isDurationOverrideEnabled ? (
<div className="mt-4">
<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>
) : null}
</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">{copy.admin.views.sound.notesTitle}</p>
<div className="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
{copy.admin.views.sound.extractedDurationLabel}
</p>
<p className="mt-2 text-sm font-medium text-slate-900">
{lastExtractedDurationSec == null
? copy.admin.views.sound.extractedDurationEmpty
: copy.admin.views.sound.extractedDurationValue(lastExtractedDurationSec)}
</p>
</div>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
{copy.admin.views.sound.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
<MessagePanel message={currentMessage} />
<Button
type="submit"
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
disabled={soundPending}
>
{soundPending ? copy.admin.views.sound.pending : copy.admin.views.sound.submit}
</Button>
</div>
</div>
</form>
);
};
export const AdminDashboardView = ({
session,
activeView,
onActiveViewChange,
activeMeta,
isDurationOverrideEnabled,
onDurationOverrideChange,
currentMessage,
uploadResult,
resultSummary,
lastExtractedDurationSec,
scenePending,
soundPending,
onLogout,
onSceneSubmit,
onSoundSubmit,
}: AdminDashboardViewProps) => {
return (
<main className="min-h-screen bg-[#f3f4f8] text-slate-900">
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="flex flex-col bg-[#171821] px-5 py-6 text-white">
<div className="flex items-center gap-3 px-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-fuchsia-500 to-sky-500 text-lg font-bold">
V
</div>
<div>
<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">
{copy.admin.navItems[0].section}
</p>
</div>
<div className="space-y-1">
{navItems.map((item) => {
const isActive = item.id === activeView;
return (
<button
key={item.id}
type="button"
onClick={() => onActiveViewChange(item.id)}
className={`flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition ${
isActive
? 'bg-white/10 text-white'
: 'text-slate-400 hover:bg-white/6 hover:text-white'
}`}
>
<span className={`h-8 w-1 rounded-full ${isActive ? 'bg-sky-400' : 'bg-transparent'}`} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title}</p>
<p className="truncate text-xs text-slate-500">{item.subtitle}</p>
</div>
</button>
);
})}
</div>
<div className="mt-10 px-3">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
{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">{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">{copy.admin.sessionRoleLabel}</p>
<p className="mt-1 font-medium text-emerald-300">
{session.user?.grade ?? 'ADMIN'}
{copy.admin.roleAccessSuffix}
</p>
</div>
</div>
</div>
</nav>
<div className="border-t border-white/8 px-3 pt-5">
<Button
type="button"
variant="ghost"
size="full"
className="h-11 justify-start rounded-xl bg-white/6 px-4 text-white hover:bg-white/10"
onClick={onLogout}
>
{copy.admin.logout}
</Button>
</div>
</aside>
<div className="flex min-w-0 flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
<div className="flex min-w-[280px] flex-1 items-center gap-3">
<button
type="button"
className="flex h-11 w-11 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500"
>
</button>
<div className="relative w-full max-w-md">
<input
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"
/>
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
</span>
</div>
</div>
<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" />
{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 ?? copy.common.admin}</p>
<p className="text-xs text-slate-500">{session.user?.email}</p>
</div>
</div>
</div>
</div>
</header>
<section className="flex-1 overflow-auto px-6 py-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="min-w-0 space-y-6">
<div className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_repeat(2,minmax(0,1fr))]">
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
{activeMeta.eyebrow}
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
{activeMeta.title}
</h1>
<p className="mt-3 text-sm leading-6 text-slate-500">{activeMeta.description}</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">{activeMeta.statTitle}</p>
<p className="mt-6 text-3xl font-semibold tracking-tight text-slate-950">{activeMeta.statValue}</p>
<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">{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">{copy.admin.inspector.bearerTokenSession}</p>
</div>
</div>
{activeView === 'scene' ? (
<AdminSceneForm
currentMessage={currentMessage}
scenePending={scenePending}
onSubmit={onSceneSubmit}
/>
) : (
<AdminSoundForm
currentMessage={currentMessage}
isDurationOverrideEnabled={isDurationOverrideEnabled}
lastExtractedDurationSec={lastExtractedDurationSec}
onDurationOverrideChange={onDurationOverrideChange}
onSubmit={onSoundSubmit}
soundPending={soundPending}
/>
)}
</div>
<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">{copy.admin.inspector.recentResponse}</p>
</div>
<div className="px-5 py-4">
<p className="text-sm leading-6 text-slate-500">{resultSummary}</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) : 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">{copy.admin.inspector.sessionToken}</p>
</div>
<div className="px-5 py-4">
<p className="break-all text-xs leading-6 text-slate-500">{session.accessToken}</p>
</div>
</div>
</aside>
</div>
</section>
</div>
</div>
</main>
);
};

View File

@@ -1,17 +1,21 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildFocusEntryStartHref,
type FocusPlanItem,
type FocusPlanToday,
useFocusPlan,
} from '@/entities/focus-plan';
import { usePlanTier } from '@/entities/plan';
import { SCENE_THEMES, getSceneById } from '@/entities/scene';
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
import { SOUND_PRESETS } from '@/entities/session';
import { PaywallSheetContent } from '@/features/paywall-sheet';
import { PlanPill } from '@/features/plan-pill';
import { useFocusStats } from '@/features/stats';
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
@@ -20,24 +24,30 @@ const FREE_MAX_ITEMS = 1;
const PRO_MAX_ITEMS = 5;
const focusEntryCopy = {
eyebrow: 'Focus Entry',
title: '지금 시작할 첫 블록',
description: '한 줄로 정하고 바로 들어가요.',
eyebrow: 'VibeRoom',
title: '오늘의 깊은 몰입을 위한 단 하나의 목표',
description: '지금 당장 시작할 딱 하나만 남겨두세요.',
inputLabel: '첫 블록',
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
helper: '아주 작게 잡아도 괜찮아요.',
startNow: '지금 시작',
manageBlocks: '블록 정리',
startNow: '바로 몰입하기',
nextStep: '환경 세팅',
manageBlocks: '내 계획에서 가져오기',
previewTitle: '이어갈 블록',
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
reviewLinkLabel: 'stats',
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
ritualMeta: '기본 ritual로 들어가요. 배경과 타이머는 /space에서 이어서 바꿀 수 있어요.',
ritualMeta: '기본 설정으로 들어갑니다. 공간 안에서 언제든 바꿀 수 있어요.',
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
freeUpgradeLabel: '두 번째 블록부터는 PRO',
paywallSource: 'focus-entry-manage-sheet',
paywallLead: 'Calm Session OS PRO',
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
microStepTitle: '가장 작은 첫 단계 (선택)',
microStepHelper: '이 목표를 위해 당장 할 수 있는 5분짜리 행동은 무엇인가요?',
microStepPlaceholder: '예: 폴더 열기, 노션 켜기',
ritualTitle: '어떤 환경에서 몰입하시겠어요?',
ritualHelper: '오늘의 무드를 선택하세요.',
};
const ENTRY_SUGGESTIONS = [
@@ -47,6 +57,7 @@ const ENTRY_SUGGESTIONS = [
] as const;
type EntrySource = 'starter' | 'plan' | 'custom';
type DashboardStep = 'goal' | 'ritual';
const getVisiblePlanItems = (
currentItem: FocusPlanItem | null,
@@ -58,76 +69,71 @@ const getVisiblePlanItems = (
.slice(0, limit);
};
const formatReviewLine = (startedSessions: number, completedSessions: number, carriedOverCount: number) => {
return `최근 7일 시작 ${startedSessions}회 · 완료 ${completedSessions}회 · 이월 ${carriedOverCount}`;
};
const startButtonClassName =
'inline-flex h-12 w-full items-center justify-center rounded-[1rem] bg-brand-primary text-sm font-semibold text-white shadow-[0_14px_32px_rgba(59,130,246,0.22)] transition hover:bg-brand-primary/92 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/18';
const previewButtonClassName =
'w-full rounded-[1.1rem] border border-slate-200/88 bg-white/72 px-4 py-3 text-left transition hover:border-slate-300/88 hover:bg-white';
const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => {
return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit);
};
// Premium Glassmorphism UI Classes
const glassInputClass = 'w-full rounded-full border border-white/20 bg-black/20 px-8 py-5 text-center text-lg md:text-xl font-light tracking-wide text-white placeholder:text-white/40 shadow-2xl backdrop-blur-xl outline-none transition-all focus:border-white/40 focus:bg-black/30 focus:ring-4 focus:ring-white/10';
const primaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/20 bg-white/20 px-8 py-4 text-base font-medium text-white shadow-xl backdrop-blur-xl transition-all hover:bg-white/30 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
const secondaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/10 bg-transparent px-8 py-4 text-base font-medium text-white/80 transition-all hover:bg-white/10 hover:text-white active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
const panelGlassClass = 'rounded-[2rem] border border-white/10 bg-black/40 p-6 md:p-8 shadow-2xl backdrop-blur-2xl';
const itemCardGlassClass = 'relative flex flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-4 text-white transition-all hover:bg-white/10 active:scale-95 cursor-pointer';
const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px_rgba(255,255,255,0.1)]';
export const FocusDashboardWidget = () => {
const router = useRouter();
const { plan: planTier, isPro, setPlan } = usePlanTier();
const { plan, isLoading, isSaving, error, source, createItem, updateItem, deleteItem } = useFocusPlan();
const { summary } = useFocusStats();
const { sceneAssetMap } = useMediaCatalog();
const [step, setStep] = useState<DashboardStep>('goal');
const [paywallSource, setPaywallSource] = useState<string | null>(null);
const [manageSheetOpen, setManageSheetOpen] = useState(false);
const [editingState, setEditingState] = useState<FocusPlanEditingState>(null);
const [entryDraft, setEntryDraft] = useState('');
const [selectedPlanItemId, setSelectedPlanItemId] = useState<string | null>(null);
const [entrySource, setEntrySource] = useState<EntrySource>('starter');
const [microStepDraft, setMicroStepDraft] = useState('');
// Use user's last preference or default to first
const [selectedSceneId, setSelectedSceneId] = useState(SCENE_THEMES[0].id);
const [selectedSoundId, setSelectedSoundId] = useState(SOUND_PRESETS[0].id);
const [selectedTimerId, setSelectedTimerId] = useState('50-10');
const [isStartingSession, setIsStartingSession] = useState(false);
const entryInputRef = useRef<HTMLInputElement | null>(null);
const microStepInputRef = useRef<HTMLInputElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]);
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
const planItems = useMemo(() => {
return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems);
}, [maxItems, plan.currentItem, plan.nextItems]);
const currentItem = planItems[0] ?? null;
const previewItems = planItems.slice(1, 3);
const reviewLine = formatReviewLine(
summary.last7Days.startedSessions,
summary.last7Days.completedSessions,
summary.last7Days.carriedOverCount,
);
const hasPendingEdit = editingState !== null;
const canAddMore = planItems.length < maxItems;
const canManagePlan = source === 'api' && !isLoading;
const trimmedEntryGoal = entryDraft.trim();
const startHref = trimmedEntryGoal
? buildFocusEntryStartHref({
goal: trimmedEntryGoal,
planItemId: selectedPlanItemId,
})
: null;
const isGoalReady = trimmedEntryGoal.length > 0;
useEffect(() => {
if (!editingState) {
return;
}
if (!editingState) return;
const rafId = window.requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
return () => {
window.cancelAnimationFrame(rafId);
};
return () => window.cancelAnimationFrame(rafId);
}, [editingState]);
useEffect(() => {
if (!currentItem) {
return;
}
if (!currentItem) return;
if (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)) {
setEntryDraft(currentItem.title);
setSelectedPlanItemId(currentItem.id);
@@ -135,34 +141,10 @@ export const FocusDashboardWidget = () => {
}
}, [currentItem, entryDraft, entrySource, selectedPlanItemId]);
useEffect(() => {
if (!selectedPlanItemId) {
return;
}
if (planItems.some((item) => item.id === selectedPlanItemId)) {
return;
}
if (currentItem) {
setEntryDraft(currentItem.title);
setSelectedPlanItemId(currentItem.id);
setEntrySource('plan');
return;
}
setEntryDraft('');
setSelectedPlanItemId(null);
setEntrySource('custom');
}, [currentItem, planItems, selectedPlanItemId]);
const openPaywall = () => {
setPaywallSource(focusEntryCopy.paywallSource);
};
const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource);
const handleSelectPlanItem = (item: FocusPlanItem) => {
const isCurrentSelection = currentItem?.id === item.id;
setEntryDraft(item.title);
setSelectedPlanItemId(isCurrentSelection ? item.id : null);
setEntrySource(isCurrentSelection ? 'plan' : 'custom');
@@ -182,111 +164,58 @@ export const FocusDashboardWidget = () => {
};
const handleAddBlock = () => {
if (hasPendingEdit || isSaving || !canManagePlan) {
return;
}
if (hasPendingEdit || isSaving || !canManagePlan) return;
if (!canAddMore) {
if (!isPro) {
openPaywall();
}
if (!isPro) openPaywall();
return;
}
setEditingState({
mode: 'new',
value: '',
});
setEditingState({ mode: 'new', value: '' });
};
const handleEditRow = (item: FocusPlanItem) => {
if (hasPendingEdit || isSaving) {
return;
}
setEditingState({
mode: 'edit',
itemId: item.id,
value: item.title,
});
if (hasPendingEdit || isSaving) return;
setEditingState({ mode: 'edit', itemId: item.id, value: item.title });
};
const handleManageDraftChange = (value: string) => {
setEditingState((current) => {
if (!current) {
return current;
}
return {
...current,
value,
};
});
setEditingState((current) => current ? { ...current, value } : current);
};
const handleCancelEdit = () => {
if (isSaving) {
return;
}
setEditingState(null);
if (!isSaving) setEditingState(null);
};
const handleSaveEdit = async () => {
if (!editingState) {
return;
}
if (!editingState) return;
const trimmedTitle = editingState.value.trim();
if (!trimmedTitle) {
return;
}
if (!trimmedTitle) return;
if (editingState.mode === 'new') {
const nextPlan = await createItem({ title: trimmedTitle });
if (!nextPlan) {
return;
}
if (!nextPlan) return;
setEditingState(null);
if (!currentItem) {
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
if (nextCurrentItem) {
setEntryDraft(nextCurrentItem.title);
setSelectedPlanItemId(nextCurrentItem.id);
setEntrySource('plan');
}
}
return;
}
const currentRow = planItems.find((item) => item.id === editingState.itemId);
if (!currentRow) {
return;
}
if (!currentRow) return;
if (currentRow.title === trimmedTitle) {
setEditingState(null);
return;
}
const nextPlan = await updateItem(editingState.itemId, {
title: trimmedTitle,
});
if (!nextPlan) {
return;
}
const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
if (!nextPlan) return;
setEditingState(null);
if (selectedPlanItemId === editingState.itemId) {
setEntryDraft(trimmedTitle);
setEntrySource('plan');
@@ -295,109 +224,144 @@ export const FocusDashboardWidget = () => {
const handleDeleteRow = async (itemId: string) => {
const nextPlan = await deleteItem(itemId);
if (!nextPlan) {
return;
}
if (!nextPlan) return;
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
setEditingState(null);
}
if (selectedPlanItemId === itemId) {
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
if (nextCurrentItem) {
setEntryDraft(nextCurrentItem.title);
setSelectedPlanItemId(nextCurrentItem.id);
setEntrySource('plan');
return;
}
setEntryDraft('');
setSelectedPlanItemId(null);
setEntrySource('custom');
}
};
const handleNextStep = () => {
if (!isGoalReady) {
entryInputRef.current?.focus();
return;
}
if (step === 'goal') setStep('ritual');
};
const handleStartSession = async () => {
if (isStartingSession) return;
setIsStartingSession(true);
try {
await focusSessionApi.startSession({
goal: trimmedEntryGoal,
microStep: microStepDraft.trim() || null,
sceneId: selectedSceneId,
soundPresetId: selectedSoundId,
timerPresetId: selectedTimerId,
focusPlanItemId: selectedPlanItemId || undefined,
entryPoint: 'space-setup'
});
router.push('/space');
} catch (err) {
console.error('Failed to start session', err);
setIsStartingSession(false);
}
};
return (
<>
<div className="min-h-screen bg-[radial-gradient(circle_at_12%_0%,rgba(191,219,254,0.42),transparent_36%),linear-gradient(180deg,#f8fafc_0%,#edf4fb_56%,#e7eef7_100%)] text-brand-dark">
<div className="mx-auto w-full max-w-2xl px-4 pb-12 pt-8 sm:px-6">
<header className="flex items-start justify-between gap-4">
<div className="max-w-lg">
<p className="text-[11px] uppercase tracking-[0.16em] text-brand-dark/40">
{focusEntryCopy.eyebrow}
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-brand-dark">
{focusEntryCopy.title}
</h1>
<p className="mt-3 text-sm leading-7 text-brand-dark/62">
{focusEntryCopy.description}
</p>
</div>
<PlanPill
plan={planTier}
onClick={() => {
if (!isPro) {
openPaywall();
}
}}
/>
</header>
<div className="relative min-h-dvh overflow-hidden bg-slate-900 text-white font-sans selection:bg-white/20">
{/* Premium Cinematic Background */}
<div
className={cn(
"absolute inset-0 bg-cover bg-center transition-all duration-1000 ease-out will-change-transform",
isStartingSession ? 'scale-110 blur-2xl opacity-0' : 'scale-100 opacity-100',
step === 'ritual' ? 'scale-105 blur-sm' : ''
)}
style={getSceneStageBackgroundStyle(selectedScene, sceneAssetMap?.[selectedScene.id])}
/>
{/* Global Gradient Overlay for text readability */}
<div className={cn(
"absolute inset-0 bg-gradient-to-b from-black/20 via-black/40 to-black/60 transition-opacity duration-1000",
step === 'ritual' ? 'opacity-80' : 'opacity-100'
)} />
<main className="mt-8 space-y-5">
<section className="overflow-hidden rounded-[2rem] border border-black/5 bg-white/78 p-5 shadow-[0_24px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl sm:p-6">
<div className="space-y-5">
<div className="space-y-1">
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.title}</p>
<p className="text-sm text-brand-dark/58">{focusEntryCopy.helper}</p>
</div>
{/* Header */}
<header className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-6 md:p-8">
<p className="text-sm font-semibold tracking-[0.3em] text-white/50 uppercase">
{focusEntryCopy.eyebrow}
</p>
<PlanPill
plan={planTier}
onClick={() => {
if (!isPro) openPaywall();
}}
/>
</header>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="min-w-0 flex-1 space-y-2">
<span className="text-[11px] uppercase tracking-[0.14em] text-brand-dark/40">
{focusEntryCopy.inputLabel}
</span>
<input
ref={entryInputRef}
value={entryDraft}
onChange={(event) => handleEntryDraftChange(event.target.value)}
placeholder={focusEntryCopy.inputPlaceholder}
className="h-12 w-full rounded-[1rem] border border-slate-200/88 bg-white px-4 text-[15px] text-brand-dark outline-none transition focus:border-brand-primary/38 focus:ring-2 focus:ring-brand-primary/12"
/>
</label>
{/* Main Content Area */}
<main className="relative z-10 flex h-dvh flex-col items-center justify-center px-4">
{/* Step 1: Goal Setup */}
<div className={cn(
"w-full max-w-2xl transition-all duration-700 absolute",
step === 'goal'
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 -translate-y-8 pointer-events-none'
)}>
<div className="flex flex-col items-center space-y-10 text-center">
<h1 className="text-3xl md:text-5xl font-light tracking-tight text-white drop-shadow-lg leading-tight">
{focusEntryCopy.title}
</h1>
<div className="w-full max-w-xl mx-auto space-y-8">
<input
ref={entryInputRef}
value={entryDraft}
onChange={(event) => handleEntryDraftChange(event.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
placeholder={focusEntryCopy.inputPlaceholder}
className={glassInputClass}
autoFocus
/>
{startHref ? (
<Link href={startHref} className={cn(startButtonClassName, 'sm:w-[164px]')}>
{focusEntryCopy.startNow}
</Link>
) : (
<button
type="button"
onClick={() => entryInputRef.current?.focus()}
className={cn(startButtonClassName, 'sm:w-[164px]')}
>
{focusEntryCopy.startNow}
</button>
)}
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
type="button"
onClick={handleStartSession}
disabled={!isGoalReady || isStartingSession}
className={primaryGlassBtnClass}
>
{focusEntryCopy.startNow}
</button>
<button
type="button"
onClick={handleNextStep}
disabled={!isGoalReady || isStartingSession}
className={secondaryGlassBtnClass}
>
{focusEntryCopy.nextStep}
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Suggestions / Manage - very minimal */}
<div className="pt-8 flex flex-col items-center gap-4 opacity-70 hover:opacity-100 transition-opacity">
<div className="flex flex-wrap justify-center gap-2">
{ENTRY_SUGGESTIONS.map((suggestion) => {
const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
return (
<button
key={suggestion.id}
type="button"
onClick={() => handleSelectSuggestion(suggestion.goal)}
className={cn(
'inline-flex items-center rounded-full border px-3 py-1.5 text-sm transition',
'rounded-full px-4 py-1.5 text-sm transition-all border',
isActive
? 'border-brand-primary/26 bg-brand-primary/10 text-brand-dark'
: 'border-slate-200/84 bg-white/72 text-brand-dark/68 hover:bg-white',
? 'bg-white/20 border-white text-white'
: 'bg-transparent border-white/20 text-white/70 hover:border-white/40 hover:text-white'
)}
>
{suggestion.label}
@@ -405,81 +369,141 @@ export const FocusDashboardWidget = () => {
);
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200/80 pt-4">
<p className="text-xs text-brand-dark/54">{focusEntryCopy.ritualMeta}</p>
<button
type="button"
onClick={() => setManageSheetOpen(true)}
disabled={!canManagePlan}
className="text-sm font-medium text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:text-brand-dark/34"
>
{focusEntryCopy.manageBlocks}
</button>
</div>
{previewItems.length > 0 ? (
<div className="space-y-3 border-t border-slate-200/80 pt-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.previewTitle}</p>
<p className="text-xs leading-6 text-brand-dark/54">
{focusEntryCopy.previewDescription}
</p>
</div>
<div className="grid gap-2">
{previewItems.map((item) => {
const isSelected = selectedPlanItemId === null && trimmedEntryGoal === item.title;
return (
<button
key={item.id}
type="button"
onClick={() => handleSelectPlanItem(item)}
className={cn(
previewButtonClassName,
isSelected && 'border-brand-primary/24 bg-brand-primary/8',
)}
>
<p
className={cn(
'truncate text-[15px] font-medium',
isSelected ? 'text-brand-dark' : 'text-brand-dark/78',
)}
>
{item.title}
</p>
</button>
);
})}
</div>
</div>
) : null}
{source === 'unavailable' && !isLoading ? (
<p className="border-t border-slate-200/80 pt-4 text-xs text-brand-dark/54">
{focusEntryCopy.apiUnavailableNote}
</p>
) : null}
<button
type="button"
onClick={() => setManageSheetOpen(true)}
disabled={!canManagePlan}
className="text-sm font-medium text-white/50 hover:text-white transition-colors underline underline-offset-4 decoration-white/20"
>
{focusEntryCopy.manageBlocks}
</button>
</div>
</section>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 px-1">
<p className="text-xs text-brand-dark/54">
{isLoading ? focusEntryCopy.reviewFallback : reviewLine}
</p>
<Link
href="/stats"
className="text-xs font-medium text-brand-primary transition hover:text-brand-primary/82"
{/* Step 2: Ritual Setup */}
<div className={cn(
"w-full max-w-4xl transition-all duration-700 absolute",
step === 'ritual'
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 translate-y-8 pointer-events-none'
)}>
<div className={panelGlassClass}>
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
<div className="flex-1">
<p className="text-xs uppercase tracking-widest text-white/40 mb-2">Today's Focus</p>
<p className="text-xl md:text-2xl font-light text-white truncate pr-4">{trimmedEntryGoal}</p>
</div>
<button
type="button"
onClick={() => setStep('goal')}
className="rounded-full border border-white/20 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors"
>
{focusEntryCopy.reviewLinkLabel}
</Link>
수정
</button>
</div>
{error && source === 'api' ? <p className="px-1 text-xs text-rose-500">{error}</p> : null}
</main>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
<div className="space-y-8">
{/* Microstep */}
<div className="space-y-3">
<label className="block space-y-2">
<span className="text-sm font-medium text-white/80">
{focusEntryCopy.microStepTitle}
</span>
<input
ref={microStepInputRef}
value={microStepDraft}
onChange={(e) => setMicroStepDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStartSession()}
placeholder={focusEntryCopy.microStepPlaceholder}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder:text-white/30 outline-none transition-all focus:border-white/30 focus:bg-white/10"
/>
</label>
<p className="text-xs text-white/40">{focusEntryCopy.microStepHelper}</p>
</div>
{/* Timer */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80">몰입 리듬</p>
<div className="grid grid-cols-3 gap-2">
{[{id: '25-5', label: '25 / 5'}, {id: '50-10', label: '50 / 10'}, {id: '90-20', label: '90 / 20'}].map(timer => (
<button
key={timer.id}
type="button"
onClick={() => setSelectedTimerId(timer.id)}
className={cn(itemCardGlassClass, selectedTimerId === timer.id && itemCardGlassSelectedClass)}
>
<span className="text-sm font-medium">{timer.label}</span>
</button>
))}
</div>
</div>
</div>
<div className="space-y-8">
{/* Scene */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80">배경 공간</p>
<div className="flex gap-3 overflow-x-auto pb-2 snap-x hide-scrollbar">
{SCENE_THEMES.map(scene => (
<button
key={scene.id}
type="button"
onClick={() => setSelectedSceneId(scene.id)}
className={cn(
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left snap-start transition-all overflow-hidden bg-white/5 active:scale-95 cursor-pointer',
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]'
)}
style={getSceneStageBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
>
<div className="absolute inset-0 bg-black/40 transition-opacity group-hover:bg-black/20" />
<span className="absolute bottom-2 left-2 text-sm font-medium z-10 text-white text-shadow-sm">{scene.name}</span>
{selectedSceneId === scene.id && (
<span className="absolute top-2 right-2 z-20 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm border border-white/40 text-white text-[10px]">
</span>
)}
</button>
))}
</div>
</div>
{/* Sound */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80">사운드</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{SOUND_PRESETS.map(sound => (
<button
key={sound.id}
type="button"
onClick={() => setSelectedSoundId(sound.id)}
className={cn(itemCardGlassClass, "py-3", selectedSoundId === sound.id && itemCardGlassSelectedClass)}
>
<span className="text-sm font-medium">{sound.label}</span>
</button>
))}
</div>
</div>
</div>
</div>
<div className="mt-10 pt-6 flex justify-end border-t border-white/10">
<button
type="button"
onClick={handleStartSession}
disabled={isStartingSession}
className={primaryGlassBtnClass}
>
{isStartingSession ? ' ...' : ''}
</button>
</div>
</div>
</div>
</main>
{/* Plan Sheet & Paywall */}
<FocusPlanManageSheet
isOpen={manageSheetOpen}
planItems={planItems}
@@ -533,6 +557,6 @@ export const FocusDashboardWidget = () => {
</div>
</div>
) : null}
</>
</div>
);
};
};

View File

@@ -0,0 +1,63 @@
'use client';
import { useState } from 'react';
import { cn } from '@/shared/lib/cn';
import { copy } from '@/shared/i18n';
interface FloatingGoalWidgetProps {
goal: string;
microStep?: string | null;
onGoalCompleteRequest?: () => void;
hasActiveSession?: boolean;
sessionPhase?: 'focus' | 'break' | null;
}
export const FloatingGoalWidget = ({
goal,
microStep,
onGoalCompleteRequest,
hasActiveSession,
sessionPhase,
}: FloatingGoalWidgetProps) => {
const [isMicroStepCompleted, setIsMicroStepCompleted] = useState(false);
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.timerHud.goalFallback;
return (
<div className="pointer-events-none fixed left-0 top-0 z-20 w-full max-w-[800px] h-48 bg-[radial-gradient(ellipse_at_top_left,rgba(0,0,0,0.6)_0%,rgba(0,0,0,0)_60%)]">
<div className="flex flex-col items-start gap-4 p-8 md:p-12">
{/* Main Goal */}
<div className="pointer-events-auto group relative flex items-center gap-4">
<h2 className="text-2xl md:text-[1.75rem] font-medium tracking-tight text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)] [text-shadow:0_4px_24px_rgba(0,0,0,0.6)]">
{normalizedGoal}
</h2>
{hasActiveSession && sessionPhase === 'focus' ? (
<button
type="button"
onClick={onGoalCompleteRequest}
className="opacity-0 group-hover:opacity-100 shrink-0 rounded-full border border-white/20 bg-black/40 backdrop-blur-md px-3.5 py-1.5 text-[11px] font-medium text-white/90 shadow-lg transition-all hover:bg-black/60 hover:text-white"
>
</button>
) : null}
</div>
{/* Micro Step */}
{microStep && !isMicroStepCompleted && (
<div className="pointer-events-auto flex items-center gap-3.5 animate-in fade-in slide-in-from-top-2 duration-500 bg-black/10 backdrop-blur-[2px] rounded-full pr-4 py-1 -ml-1 border border-white/5">
<button
type="button"
onClick={() => setIsMicroStepCompleted(true)}
className="flex h-6 w-6 ml-1 items-center justify-center rounded-full border border-white/40 bg-black/20 shadow-inner transition-all hover:bg-white/20 hover:scale-110 active:scale-95"
aria-label="첫 단계 완료"
>
<span className="sr-only"> </span>
</button>
<span className="text-[15px] font-medium text-white/95 drop-shadow-[0_2px_4px_rgba(0,0,0,0.6)] [text-shadow:0_2px_12px_rgba(0,0,0,0.5)]">
{microStep}
</span>
</div>
)}
</div>
</div>
);
};

View File

@@ -8,13 +8,11 @@ import { cn } from '@/shared/lib/cn';
interface GoalCompleteSheetProps {
open: boolean;
currentGoal: string;
onConfirm: (nextGoal: string) => void;
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
onRest: () => void;
onClose: () => void;
}
const GOAL_SUGGESTIONS = copy.space.goalComplete.suggestions;
export const GoalCompleteSheet = ({
open,
currentGoal,
@@ -24,6 +22,7 @@ export const GoalCompleteSheet = ({
}: GoalCompleteSheetProps) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState('');
const [isSubmitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) {
@@ -56,14 +55,24 @@ export const GoalCompleteSheet = ({
}, [currentGoal]);
const canConfirm = draft.trim().length > 0;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canConfirm) {
if (!canConfirm || isSubmitting) {
return;
}
onConfirm(draft.trim());
setSubmitting(true);
try {
const didAdvance = await onConfirm(draft.trim());
if (didAdvance) {
onClose();
}
} finally {
setSubmitting(false);
}
};
return (
@@ -99,34 +108,20 @@ export const GoalCompleteSheet = ({
placeholder={placeholder}
className="h-9 w-full rounded-xl border border-white/14 bg-white/[0.04] px-3 text-sm text-white placeholder:text-white/40 focus:border-sky-200/42 focus:outline-none"
/>
<div className="flex flex-wrap gap-1.5">
{GOAL_SUGGESTIONS.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => setDraft(suggestion)}
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/76 transition-colors hover:bg-white/[0.1]"
>
{suggestion}
</button>
))}
</div>
<footer className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
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>
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}
disabled={!canConfirm || isSubmitting}
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}
{isSubmitting ? copy.space.goalComplete.confirmPending : copy.space.goalComplete.confirmButton}
</button>
</footer>
</form>

View File

@@ -2,10 +2,12 @@ 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 { FloatingGoalWidget } from './FloatingGoalWidget';
import { GoalCompleteSheet } from './GoalCompleteSheet';
interface SpaceFocusHudWidgetProps {
goal: string;
microStep?: string | null;
timerLabel: string;
timeDisplay?: string;
visible: boolean;
@@ -19,12 +21,13 @@ interface SpaceFocusHudWidgetProps {
onStartRequested?: () => void;
onPauseRequested?: () => void;
onRestartRequested?: () => void;
onGoalUpdate: (nextGoal: string) => void | Promise<void>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void;
}
export const SpaceFocusHudWidget = ({
goal,
microStep,
timerLabel,
timeDisplay,
visible,
@@ -86,9 +89,15 @@ export const SpaceFocusHudWidget = ({
return (
<>
<FloatingGoalWidget
goal={goal}
microStep={microStep}
onGoalCompleteRequest={handleOpenCompleteSheet}
hasActiveSession={hasActiveSession}
sessionPhase={sessionPhase}
/>
<SpaceTimerHudWidget
timerLabel={timerLabel}
goal={goal}
timeDisplay={timeDisplay}
isImmersionMode
hasActiveSession={hasActiveSession}
@@ -99,7 +108,6 @@ export const SpaceFocusHudWidget = ({
canPause={canPauseSession}
canReset={canRestartSession}
className="pr-[4.2rem]"
onGoalCompleteRequest={handleOpenCompleteSheet}
onStartClick={onStartRequested}
onPauseClick={onPauseRequested}
onResetClick={onRestartRequested}
@@ -121,8 +129,7 @@ export const SpaceFocusHudWidget = ({
}, 5 * 60 * 1000);
}}
onConfirm={(nextGoal) => {
void onGoalUpdate(nextGoal);
setSheetOpen(false);
return Promise.resolve(onGoalUpdate(nextGoal));
}}
/>
</>

View File

@@ -3,14 +3,18 @@
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 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';
import { cn } from '@/shared/lib/cn';
type RitualPopover = 'space' | 'timer' | 'sound';
type SelectionPopover = 'space' | 'timer' | 'sound';
interface SpaceSetupDrawerWidgetProps {
open: boolean;
@@ -86,7 +90,7 @@ export const SpaceSetupDrawerWidget = ({
resumeHint,
}: SpaceSetupDrawerWidgetProps) => {
const { setup } = copy.space;
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
const [openPopover, setOpenPopover] = useState<SelectionPopover | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const selectedScene = useMemo(() => {
@@ -133,7 +137,7 @@ export const SpaceSetupDrawerWidget = ({
return null;
}
const togglePopover = (popover: RitualPopover) => {
const togglePopover = (popover: SelectionPopover) => {
setOpenPopover((current) => (current === popover ? null : popover));
};
@@ -280,7 +284,7 @@ export const SpaceSetupDrawerWidget = ({
) : null}
</div>
<form id="space-setup-ritual-form" className="space-y-3" onSubmit={handleSubmit}>
<form id="space-setup-form" className="space-y-3" onSubmit={handleSubmit}>
<SessionGoalField
autoFocus={open}
goalInput={goalInput}
@@ -294,7 +298,7 @@ export const SpaceSetupDrawerWidget = ({
{!canStart ? <p className="text-[10px] text-white/56">{setup.readyHint}</p> : null}
<Button
type="submit"
form="space-setup-ritual-form"
form="space-setup-form"
size="full"
disabled={!canStart}
className={cn(

View File

@@ -10,7 +10,6 @@ import {
interface SpaceTimerHudWidgetProps {
timerLabel: string;
goal: string;
timeDisplay?: string;
className?: string;
hasActiveSession?: boolean;
@@ -24,14 +23,12 @@ interface SpaceTimerHudWidgetProps {
onStartClick?: () => void;
onPauseClick?: () => void;
onResetClick?: () => void;
onGoalCompleteRequest?: () => void;
}
const HUD_ACTIONS = copy.space.timerHud.actions;
export const SpaceTimerHudWidget = ({
timerLabel,
goal,
timeDisplay = '25:00',
className,
hasActiveSession = false,
@@ -45,10 +42,8 @@ export const SpaceTimerHudWidget = ({
onStartClick,
onPauseClick,
onResetClick,
onGoalCompleteRequest,
}: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.timerHud.goalFallback;
const modeLabel = isBreatheMode
? RECOVERY_30S_MODE_LABEL
: !hasActiveSession
@@ -60,120 +55,85 @@ export const SpaceTimerHudWidget = ({
return (
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-20 px-4 pr-16 sm:px-6',
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 sm:px-6',
className,
)}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 0.35rem)' }}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 2rem)' }}
>
<div className="relative mx-auto w-full max-w-xl pointer-events-auto">
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 z-0 h-28 w-[min(760px,96vw)] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.2)_0%,rgba(2,6,23,0.12)_45%,rgba(2,6,23,0)_78%)]"
/>
<div className="relative pointer-events-auto">
<section
className={cn(
'relative z-10 flex h-[4.85rem] items-center justify-between gap-3 overflow-hidden rounded-2xl px-3.5 py-2 transition-colors',
'relative z-10 flex h-[3.5rem] items-center justify-between gap-6 overflow-hidden rounded-full px-5 transition-colors',
isImmersionMode
? 'border border-white/12 bg-black/22 backdrop-blur-md'
: 'border border-white/12 bg-black/24 backdrop-blur-md',
? 'border border-white/10 bg-black/20 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)]'
: 'border border-white/15 bg-black/30 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.16)]',
)}
>
<div className="min-w-0">
<div className="flex items-baseline gap-2">
<span
className={cn(
'text-[11px] font-semibold uppercase tracking-[0.16em]',
isImmersionMode ? 'text-white/90' : 'text-white/88',
)}
>
{modeLabel}
</span>
<span
className={cn(
'text-[1.7rem] font-semibold tracking-tight sm:text-[1.78rem]',
isImmersionMode ? 'text-white/90' : 'text-white/92',
)}
>
{timeDisplay}
</span>
<span className={cn('text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
{timerLabel}
</span>
</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">{copy.space.timerHud.goalPrefix}</span>
<span className="text-white/90">{normalizedGoal}</span>
</p>
<button
type="button"
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 className="flex items-center gap-3">
<span
className={cn(
'w-14 text-right text-[10px] font-bold uppercase tracking-[0.15em] opacity-80',
sessionPhase === 'break' ? 'text-emerald-400' : 'text-brand-primary'
)}
>
{modeLabel}
</span>
<span className="w-[1px] h-4 bg-white/10" />
<span
className={cn(
'w-20 text-[1.4rem] font-medium tracking-tight text-center',
isImmersionMode ? 'text-white/90' : 'text-white',
)}
>
{timeDisplay}
</span>
</div>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-1.5">
{HUD_ACTIONS.map((action) => {
const isStartAction = action.id === 'start';
const isPauseAction = action.id === 'pause';
const isResetAction = action.id === 'reset';
const isDisabled =
isControlsDisabled ||
(isStartAction ? !canStart : isPauseAction ? !canPause : !canReset);
const isHighlighted =
(isStartAction && playbackState !== 'running') ||
(isPauseAction && playbackState === 'running');
<div className="flex items-center gap-1.5 pl-2 border-l border-white/10">
{HUD_ACTIONS.map((action) => {
const isStartAction = action.id === 'start';
const isPauseAction = action.id === 'pause';
const isResetAction = action.id === 'reset';
const isDisabled =
isControlsDisabled ||
(isStartAction ? !canStart : isPauseAction ? !canPause : !canReset);
const isHighlighted =
(isStartAction && playbackState !== 'running') ||
(isPauseAction && playbackState === 'running');
return (
<button
key={action.id}
type="button"
title={action.label}
aria-pressed={isHighlighted}
disabled={isDisabled}
onClick={() => {
if (isStartAction) {
onStartClick?.();
}
if (isPauseAction) {
onPauseClick?.();
}
if (isResetAction) {
onResetClick?.();
}
}}
className={cn(
'inline-flex h-9 w-9 items-center justify-center rounded-full border text-sm transition-[transform,background-color,border-color,box-shadow,color,opacity] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80 active:translate-y-px active:scale-[0.95] disabled:cursor-not-allowed disabled:opacity-38 disabled:shadow-none',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_8px_18px_rgba(2,6,23,0.18)]',
isImmersionMode
? 'border-white/14 bg-black/28 text-white/82 hover:border-white/22 hover:bg-white/[0.09]'
: 'border-white/14 bg-black/28 text-white/84 hover:border-white/22 hover:bg-white/[0.09]',
isStartAction && isHighlighted
? 'border-sky-200/56 bg-sky-200/20 text-sky-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(56,189,248,0.24)]'
: '',
isPauseAction && isHighlighted
? 'border-amber-200/52 bg-amber-200/18 text-amber-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(251,191,36,0.18)]'
: '',
isResetAction && !isDisabled
? 'hover:border-white/26 hover:bg-white/[0.12] hover:text-white'
: '',
)}
>
<span aria-hidden>{action.icon}</span>
<span className="sr-only">{action.label}</span>
</button>
);
})}
</div>
return (
<button
key={action.id}
type="button"
title={action.label}
aria-pressed={isHighlighted}
disabled={isDisabled}
onClick={() => {
if (isStartAction) onStartClick?.();
if (isPauseAction) onPauseClick?.();
if (isResetAction) onResetClick?.();
}}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-full text-sm transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 active:scale-95 disabled:cursor-not-allowed disabled:opacity-30',
isImmersionMode
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-white/80 hover:bg-white/15 hover:text-white',
isStartAction && isHighlighted
? 'bg-white/10 text-white shadow-sm'
: '',
isPauseAction && isHighlighted
? 'bg-white/10 text-white shadow-sm'
: '',
)}
>
<span aria-hidden>{action.icon}</span>
<span className="sr-only">{action.label}</span>
</button>
);
})}
<Restart30sAction
onTrigger={triggerRestart}
className={cn(isImmersionMode ? 'text-white/72 hover:text-white/92' : 'text-white/74 hover:text-white/92')}
className="h-8 w-8 ml-1"
/>
</div>
</section>

View File

@@ -1,11 +1,11 @@
'use client';
import { useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { usePlanTier } from '@/entities/plan';
import type { RecentThought } from '@/entities/session';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
import type { PlanTier } from '@/entities/plan';
interface UseSpaceToolsDockHandlersParams {
setIdle: (idle: boolean) => void;
@@ -44,7 +44,7 @@ export const useSpaceToolsDockHandlers = ({
}: UseSpaceToolsDockHandlersParams) => {
const { toolsDock } = copy.space;
const [noteDraft, setNoteDraft] = useState('');
const [plan, setPlan] = useState<PlanTier>('normal');
const { plan, setPlan } = usePlanTier();
const openUtilityPanel = useCallback((panel: SpaceUtilityPanelId) => {
setIdle(false);
@@ -148,11 +148,11 @@ export const useSpaceToolsDockHandlers = ({
const handleSelectProFeature = useCallback((featureId: string) => {
const label =
featureId === 'scene-packs'
? toolsDock.featureLabels.scenePacks
: featureId === 'sound-packs'
? toolsDock.featureLabels.soundPacks
: toolsDock.featureLabels.profiles;
featureId === 'daily-plan'
? toolsDock.featureLabels.dailyPlan
: featureId === 'rituals'
? toolsDock.featureLabels.rituals
: toolsDock.featureLabels.weeklyReview;
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
}, [onStatusMessage, toolsDock.featureLabels]);

View File

@@ -79,82 +79,32 @@ export const FocusModeAnchors = ({
type="button"
aria-label={copy.space.toolsDock.popoverCloseAria}
onClick={onClosePopover}
className="fixed inset-0 z-30"
className="fixed inset-0 z-30 cursor-default"
/>
) : null}
<FocusRightRail
isIdle={isIdle}
thoughtCount={thoughtCount}
openPopover={openPopover}
noteDraft={noteDraft}
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onOpenInbox={onOpenInbox}
onOpenControlCenter={onOpenControlCenter}
onToggleNotes={onToggleNotes}
onToggleSound={onToggleSound}
onNoteDraftChange={onNoteDraftChange}
onNoteSubmit={onNoteSubmit}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
<div
className={cn(
anchorContainerClassName,
'left-[calc(env(safe-area-inset-left,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleNotes}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
<span>{copy.space.toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'notes' ? (
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
) : null}
</div>
</div>
<div
className={cn(
anchorContainerClassName,
'right-[calc(env(safe-area-inset-right,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleSound}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'sound' ? (
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
) : null}
</div>
</div>
</>
);
};

View File

@@ -1,53 +1,179 @@
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { SoundPreset } from '@/entities/session';
import { cn } from '@/shared/lib/cn';
import { copy } from '@/shared/i18n';
import { formatThoughtCount, RAIL_ICON } from './constants';
import type { SpaceAnchorPopoverId } from '../model/types';
import { formatThoughtCount, RAIL_ICON, ANCHOR_ICON } from './constants';
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
interface FocusRightRailProps {
isIdle: boolean;
thoughtCount: number;
openPopover: SpaceAnchorPopoverId | null;
noteDraft: string;
selectedSoundLabel: string;
isSoundMuted: boolean;
soundVolume: number;
volumeFeedback: string | null;
quickSoundPresets: SoundPreset[];
selectedPresetId: string;
onOpenInbox: () => void;
onOpenControlCenter: () => void;
onToggleNotes: () => void;
onToggleSound: () => void;
onNoteDraftChange: (value: string) => void;
onNoteSubmit: () => void;
onToggleMute: () => void;
onVolumeChange: (nextVolume: number) => void;
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
onSelectPreset: (presetId: string) => void;
}
export const FocusRightRail = ({
isIdle,
thoughtCount,
openPopover,
noteDraft,
selectedSoundLabel,
isSoundMuted,
soundVolume,
volumeFeedback,
quickSoundPresets,
selectedPresetId,
onOpenInbox,
onOpenControlCenter,
onToggleNotes,
onToggleSound,
onNoteDraftChange,
onNoteSubmit,
onToggleMute,
onVolumeChange,
onVolumeKeyDown,
onSelectPreset,
}: FocusRightRailProps) => {
return (
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-34' : 'opacity-78',
'fixed z-30 transition-all duration-500 right-[calc(env(safe-area-inset-right,0px)+1.5rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-0 translate-x-4 pointer-events-none' : 'opacity-100 translate-x-0',
)}
>
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
<div className="flex flex-col gap-1">
<div className="relative flex flex-col gap-2 rounded-full border border-white/10 bg-black/20 p-2.5 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.2)]">
{/* Notes Toggle */}
<div className="relative group">
<button
type="button"
aria-label={copy.space.toolsDock.notesButton}
onClick={onToggleNotes}
className={cn(
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
openPopover === 'notes' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
)}
>
{ANCHOR_ICON.notes}
</button>
{/* Tooltip */}
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.toolsDock.notesButton}
</span>
</div>
{openPopover === 'notes' ? (
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
</div>
) : null}
</div>
{/* Sound Toggle */}
<div className="relative group">
<button
type="button"
aria-label="사운드"
onClick={onToggleSound}
className={cn(
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
openPopover === 'sound' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
)}
>
{ANCHOR_ICON.sound}
</button>
{/* Tooltip */}
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
</span>
</div>
{openPopover === 'sound' ? (
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
</div>
) : null}
</div>
<div className="w-6 h-px bg-white/10 mx-auto my-1" />
{/* Inbox Button */}
<div className="relative group">
<button
type="button"
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"
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{RAIL_ICON.inbox}
{thoughtCount > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
<span className="absolute 0 top-0 right-0 inline-flex min-w-[1rem] items-center justify-center rounded-full bg-brand-primary px-1 py-0.5 text-[9px] font-bold text-white shadow-sm ring-2 ring-black/20">
{formatThoughtCount(thoughtCount)}
</span>
) : null}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.inbox.openInboxTitle}
</span>
</div>
</div>
{/* Control Center Button */}
<div className="relative group">
<button
type="button"
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"
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{RAIL_ICON.controlCenter}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.rightRail.openQuickControlsTitle}
</span>
</div>
</div>
</div>
</div>
);

View File

@@ -15,11 +15,10 @@ export const QuickNotesPopover = ({
}: QuickNotesPopoverProps) => {
return (
<div
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 }}
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
>
<p className="text-[11px] text-white/56">{copy.space.quickNotes.title}</p>
<div className="mt-2 flex gap-1.5">
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickNotes.title}</p>
<div className="mt-4 flex flex-col gap-3">
<input
value={noteDraft}
onChange={(event) => onDraftChange(event.target.value)}
@@ -32,17 +31,21 @@ export const QuickNotesPopover = ({
onDraftEnter();
}}
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"
autoFocus
className="w-full border-b border-white/20 bg-transparent pb-2 text-sm text-white placeholder:text-white/30 transition-colors focus:border-white/60 focus:outline-none"
/>
<button
type="button"
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 className="flex items-center justify-between">
<p className="text-[10px] text-white/40">{copy.space.quickNotes.hint}</p>
<button
type="button"
onClick={onSubmit}
disabled={!noteDraft.trim()}
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium text-white transition-all hover:bg-white/20 active:scale-95 disabled:opacity-30"
>
{copy.space.quickNotes.submit}
</button>
</div>
</div>
<p className="mt-2 text-[11px] text-white/52">{copy.space.quickNotes.hint}</p>
</div>
);
};

View File

@@ -30,60 +30,71 @@ export const QuickSoundPopover = ({
}: QuickSoundPopoverProps) => {
return (
<div
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 }}
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
>
<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="flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickSound.currentSound}</p>
<span className="text-[11px] font-medium text-white/90 bg-white/10 px-2 py-0.5 rounded-md">
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
</span>
</div>
<p className="mt-2 truncate text-base font-medium text-white/90">{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">
<div className="mt-5 rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur-md">
<div className="flex items-center gap-3">
<button
type="button"
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]"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-[13px] text-white/80 transition-all hover:bg-white/20 active:scale-95"
>
🔇
{isSoundMuted ? '🔇' : '🔊'}
</button>
<input
type="range"
min={0}
max={100}
step={1}
value={soundVolume}
onChange={(event) => onVolumeChange(Number(event.target.value))}
onKeyDown={onVolumeKeyDown}
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">
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
</span>
<div className="relative flex w-full items-center">
<input
type="range"
min={0}
max={100}
step={1}
value={soundVolume}
onChange={(event) => onVolumeChange(Number(event.target.value))}
onKeyDown={onVolumeKeyDown}
aria-label={copy.space.quickSound.volumeAriaLabel}
className="absolute z-10 w-full cursor-pointer appearance-none bg-transparent accent-white outline-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
/>
<div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
<div
className="h-full bg-white/90 transition-all duration-150 ease-out"
style={{ width: `${soundVolume}%` }}
/>
</div>
</div>
</div>
</div>
<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;
<div className="mt-6">
<p className="text-[10px] font-medium uppercase tracking-widest text-white/40 mb-3">{copy.space.quickSound.quickSwitch}</p>
<div className="flex flex-wrap gap-2">
{quickSoundPresets.map((preset) => {
const selected = preset.id === selectedPresetId;
return (
<button
key={preset.id}
type="button"
onClick={() => onSelectPreset(preset.id)}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)}
>
{preset.label}
</button>
);
})}
return (
<button
key={preset.id}
type="button"
onClick={() => onSelectPreset(preset.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-[11px] font-medium transition-all active:scale-95',
selected
? 'border-white/40 bg-white/20 text-white shadow-sm'
: 'border-transparent bg-white/5 text-white/60 hover:bg-white/15 hover:text-white',
)}
>
{preset.label}
</button>
);
})}
</div>
</div>
</div>
);

View File

@@ -28,6 +28,7 @@ import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics';
interface UseSpaceWorkspaceSelectionParams {
initialSceneId: string;
initialGoal: string;
initialFocusPlanItemId: string | null;
initialTimerLabel: string;
sceneQuery: string | null;
goalQuery: string;
@@ -64,6 +65,7 @@ const getVisibleSetupScenes = (selectedScene: SceneTheme) => {
export const useSpaceWorkspaceSelection = ({
initialSceneId,
initialGoal,
initialFocusPlanItemId,
initialTimerLabel,
sceneQuery,
goalQuery,
@@ -86,6 +88,7 @@ export const useSpaceWorkspaceSelection = ({
const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId);
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
const [goalInput, setGoalInput] = useState(initialGoal);
const [linkedFocusPlanItemId, setLinkedFocusPlanItemId] = useState<string | null>(initialFocusPlanItemId);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [resumeGoal, setResumeGoal] = useState('');
const [showResumePrompt, setShowResumePrompt] = useState(false);
@@ -262,6 +265,7 @@ export const useSpaceWorkspaceSelection = ({
const handleGoalChipSelect = useCallback((chip: GoalChip) => {
setShowResumePrompt(false);
setLinkedFocusPlanItemId(null);
setSelectedGoalId(chip.id);
setGoalInput(chip.label);
}, []);
@@ -271,6 +275,7 @@ export const useSpaceWorkspaceSelection = ({
setShowResumePrompt(false);
}
setLinkedFocusPlanItemId(null);
setGoalInput(value);
if (value.trim().length === 0) {
@@ -385,6 +390,7 @@ export const useSpaceWorkspaceSelection = ({
setSelectedTimerLabel(nextTimerLabel);
setSelectedPresetId(nextSoundPresetId);
setGoalInput(currentSession.goal);
setLinkedFocusPlanItemId(currentSession.focusPlanItemId ?? null);
setSelectedGoalId(null);
setShowResumePrompt(false);
});
@@ -418,6 +424,7 @@ export const useSpaceWorkspaceSelection = ({
selectedSceneId,
selectedTimerLabel,
goalInput,
linkedFocusPlanItemId,
selectedGoalId,
resumeGoal,
showResumePrompt,
@@ -428,6 +435,7 @@ export const useSpaceWorkspaceSelection = ({
setupScenes,
canStart,
setGoalInput,
setLinkedFocusPlanItemId,
setSelectedGoalId,
setShowResumePrompt,
setResumeGoal,

View File

@@ -17,6 +17,7 @@ interface UseSpaceWorkspaceSessionControlsParams {
canStart: boolean;
currentSession: FocusSession | null;
goalInput: string;
linkedFocusPlanItemId: string | null;
selectedSceneId: string;
selectedTimerLabel: string;
selectedPresetId: string;
@@ -28,18 +29,24 @@ interface UseSpaceWorkspaceSessionControlsParams {
sceneId: string;
goal: string;
timerPresetId: string;
soundPresetId: string;
soundPresetId: string | null;
focusPlanItemId?: string;
entryPoint: SessionEntryPoint;
}) => Promise<FocusSession | null>;
pauseSession: () => Promise<FocusSession | null>;
resumeSession: () => Promise<FocusSession | null>;
restartCurrentPhase: () => Promise<FocusSession | null>;
completeSession: (input: {
completionType: 'goal-complete';
advanceGoal: (input: {
completedGoal: string;
}) => Promise<FocusSession | null>;
nextGoal: string;
sceneId: string;
timerPresetId: string;
soundPresetId: string;
focusPlanItemId?: string;
}) => Promise<{ nextSession: FocusSession } | null>;
abandonSession: () => Promise<boolean>;
setGoalInput: (value: string) => void;
setLinkedFocusPlanItemId: (value: string | null) => void;
setSelectedGoalId: (value: string | null) => void;
setShowResumePrompt: (value: boolean) => void;
}
@@ -54,6 +61,7 @@ export const useSpaceWorkspaceSessionControls = ({
canStart,
currentSession,
goalInput,
linkedFocusPlanItemId,
selectedSceneId,
selectedTimerLabel,
selectedPresetId,
@@ -65,9 +73,10 @@ export const useSpaceWorkspaceSessionControls = ({
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
advanceGoal,
abandonSession,
setGoalInput,
setLinkedFocusPlanItemId,
setSelectedGoalId,
setShowResumePrompt,
}: UseSpaceWorkspaceSessionControlsParams) => {
@@ -110,6 +119,7 @@ export const useSpaceWorkspaceSessionControls = ({
goal: trimmedGoal,
timerPresetId,
soundPresetId: selectedPresetId,
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
entryPoint: pendingSessionEntryPoint,
});
@@ -129,6 +139,7 @@ export const useSpaceWorkspaceSessionControls = ({
selectedPresetId,
selectedSceneId,
selectedTimerLabel,
linkedFocusPlanItemId,
setPreviewPlaybackState,
startSession,
]);
@@ -222,33 +233,61 @@ export const useSpaceWorkspaceSessionControls = ({
const handleGoalAdvance = useCallback(async (nextGoal: string) => {
const trimmedNextGoal = nextGoal.trim();
const trimmedCurrentGoal = goalInput.trim();
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
if (!trimmedNextGoal) {
return;
if (!trimmedNextGoal || !trimmedCurrentGoal || !timerPresetId || !currentSession) {
return false;
}
if (currentSession) {
const completedSession = await completeSession({
completionType: 'goal-complete',
completedGoal: goalInput.trim(),
});
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
if (!completedSession) {
pushStatusLine({
message: copy.space.workspace.goalCompleteSyncFailed,
});
return;
}
const nextState = await advanceGoal({
completedGoal: trimmedCurrentGoal,
nextGoal: trimmedNextGoal,
sceneId: selectedSceneId,
timerPresetId,
soundPresetId: selectedPresetId,
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
});
if (!nextState) {
pushStatusLine({
message: copy.space.workspace.goalCompleteSyncFailed,
});
return false;
}
setGoalInput(trimmedNextGoal);
setLinkedFocusPlanItemId(nextState.nextSession.focusPlanItemId ?? null);
setSelectedGoalId(null);
setShowResumePrompt(false);
setPendingSessionEntryPoint('goal-complete');
setPreviewPlaybackState('paused');
setPreviewPlaybackState('running');
setWorkspaceMode('focus');
pushStatusLine({
message: copy.space.workspace.nextGoalReady,
message: copy.space.workspace.nextGoalStarted,
});
}, [completeSession, currentSession, goalInput, pushStatusLine, setGoalInput, setPendingSessionEntryPoint, setPreviewPlaybackState, setSelectedGoalId]);
return true;
}, [
advanceGoal,
currentSession,
goalInput,
linkedFocusPlanItemId,
pushStatusLine,
resolveSoundPlaybackUrl,
selectedPresetId,
selectedSceneId,
selectedTimerLabel,
setGoalInput,
setLinkedFocusPlanItemId,
setPendingSessionEntryPoint,
setPreviewPlaybackState,
setSelectedGoalId,
setShowResumePrompt,
setWorkspaceMode,
unlockPlayback,
]);
useEffect(() => {
const previousBodyOverflow = document.body.style.overflow;

View File

@@ -1,45 +1,48 @@
'use client';
"use client";
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
import {
getSceneStageBackgroundStyle,
getSceneStagePhotoUrl,
preloadAssetImage,
useMediaCatalog,
} from '@/entities/media';
} from "@/entities/media";
import { getSceneById, SCENE_THEMES } from "@/entities/scene";
import { GOAL_CHIPS, SOUND_PRESETS, useThoughtInbox } from "@/entities/session";
import { useFocusSessionEngine } from "@/features/focus-session";
import {
GOAL_CHIPS,
SOUND_PRESETS,
useThoughtInbox,
} from '@/entities/session';
import { useFocusSessionEngine } from '@/features/focus-session';
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
import type { SessionEntryPoint, WorkspaceMode } from '../model/types';
import { useSpaceWorkspaceSelection } from '../model/useSpaceWorkspaceSelection';
import { useSpaceWorkspaceSessionControls } from '../model/useSpaceWorkspaceSessionControls';
useSoundPlayback,
useSoundPresetSelection,
} from "@/features/sound-preset";
import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
import {
resolveFocusTimeDisplayFromTimerLabel,
resolveInitialSceneId,
resolveInitialSoundPreset,
resolveInitialTimerLabel,
resolveTimerLabelFromPresetId,
TIMER_SELECTION_PRESETS,
resolveFocusTimeDisplayFromTimerLabel,
} from '../model/workspaceSelection';
import { FocusTopToast } from './FocusTopToast';
} from "../model/workspaceSelection";
import { FocusTopToast } from "./FocusTopToast";
export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams();
const sceneQuery = searchParams.get('scene') ?? searchParams.get('room');
const goalQuery = searchParams.get('goal')?.trim() ?? '';
const soundQuery = searchParams.get('sound');
const timerQuery = searchParams.get('timer');
const hasQueryOverrides = Boolean(sceneQuery || goalQuery || soundQuery || timerQuery);
const router = useRouter();
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
const goalQuery = searchParams.get("goal")?.trim() ?? "";
const focusPlanItemIdQuery = searchParams.get("planItemId");
const soundQuery = searchParams.get("sound");
const timerQuery = searchParams.get("timer");
const hasQueryOverrides = Boolean(
sceneQuery || goalQuery || focusPlanItemIdQuery || soundQuery || timerQuery,
);
const {
thoughts,
@@ -60,22 +63,39 @@ export const SpaceWorkspaceWidget = () => {
hasResolvedManifest,
} = useMediaCatalog();
const initialSceneId = useMemo(() => resolveInitialSceneId(sceneQuery, undefined), [sceneQuery]);
const initialScene = useMemo(() => getSceneById(initialSceneId) ?? SCENE_THEMES[0], [initialSceneId]);
const initialSoundPresetId = useMemo(() => resolveInitialSoundPreset(
soundQuery,
undefined,
initialScene.recommendedSoundPresetId,
), [initialScene.recommendedSoundPresetId, soundQuery]);
const initialTimerLabel = useMemo(() => resolveInitialTimerLabel(
timerQuery,
undefined,
initialScene.recommendedTimerPresetId,
), [initialScene.recommendedTimerPresetId, timerQuery]);
const initialSceneId = useMemo(
() => resolveInitialSceneId(sceneQuery, undefined),
[sceneQuery],
);
const initialScene = useMemo(
() => getSceneById(initialSceneId) ?? SCENE_THEMES[0],
[initialSceneId],
);
const initialSoundPresetId = useMemo(
() =>
resolveInitialSoundPreset(
soundQuery,
undefined,
initialScene.recommendedSoundPresetId,
),
[initialScene.recommendedSoundPresetId, soundQuery],
);
const initialTimerLabel = useMemo(
() =>
resolveInitialTimerLabel(
timerQuery,
undefined,
initialScene.recommendedTimerPresetId,
),
[initialScene.recommendedTimerPresetId, timerQuery],
);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup');
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('paused');
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] = useState<SessionEntryPoint>('space-setup');
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("setup");
const [previewPlaybackState, setPreviewPlaybackState] = useState<
"running" | "paused"
>("paused");
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
useState<SessionEntryPoint>("space-setup");
const {
selectedPresetId,
@@ -85,9 +105,9 @@ export const SpaceWorkspaceWidget = () => {
isMuted,
setMuted,
} = useSoundPresetSelection(initialSoundPresetId);
const {
currentSession,
isBootstrapping,
isMutating: isSessionMutating,
timeDisplay,
playbackState,
@@ -97,15 +117,16 @@ export const SpaceWorkspaceWidget = () => {
resumeSession,
restartCurrentPhase,
updateCurrentSelection,
completeSession,
advanceGoal,
abandonSession,
} = useFocusSessionEngine();
const isFocusMode = workspaceMode === 'focus';
const isFocusMode = workspaceMode === "focus";
const resolvedPlaybackState = currentSession?.state ?? previewPlaybackState;
const shouldPlaySound = isFocusMode && resolvedPlaybackState === 'running';
const shouldPlaySound = isFocusMode && resolvedPlaybackState === "running";
const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode);
const { activeStatus, pushStatusLine, runActiveAction } =
useHudStatusLine(isFocusMode);
const { error: soundPlaybackError, unlockPlayback } = useSoundPlayback({
selectedPresetId,
@@ -116,7 +137,7 @@ export const SpaceWorkspaceWidget = () => {
});
const resolveSoundPlaybackUrl = (presetId: string) => {
if (presetId === 'silent') {
if (presetId === "silent") {
return null;
}
const asset = soundAssetMap[presetId];
@@ -126,6 +147,7 @@ export const SpaceWorkspaceWidget = () => {
const selection = useSpaceWorkspaceSelection({
initialSceneId,
initialGoal: goalQuery,
initialFocusPlanItemId: focusPlanItemIdQuery,
initialTimerLabel,
sceneQuery,
goalQuery,
@@ -156,6 +178,7 @@ export const SpaceWorkspaceWidget = () => {
canStart: selection.canStart,
currentSession,
goalInput: selection.goalInput,
linkedFocusPlanItemId: selection.linkedFocusPlanItemId,
selectedSceneId: selection.selectedSceneId,
selectedTimerLabel: selection.selectedTimerLabel,
selectedPresetId,
@@ -167,27 +190,47 @@ export const SpaceWorkspaceWidget = () => {
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
advanceGoal,
abandonSession,
setGoalInput: selection.setGoalInput,
setLinkedFocusPlanItemId: selection.setLinkedFocusPlanItemId,
setSelectedGoalId: selection.setSelectedGoalId,
setShowResumePrompt: selection.setShowResumePrompt,
});
useEffect(() => {
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
router.replace("/app");
}
}, [isBootstrapping, currentSession, hasQueryOverrides, router]);
useEffect(() => {
const preferMobile =
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false;
preloadAssetImage(getSceneStagePhotoUrl(selection.selectedScene, selection.selectedSceneAsset, { preferMobile }));
typeof window !== "undefined"
? window.matchMedia("(max-width: 767px)").matches
: false;
preloadAssetImage(
getSceneStagePhotoUrl(
selection.selectedScene,
selection.selectedSceneAsset,
{ preferMobile },
),
);
}, [selection.selectedScene, selection.selectedSceneAsset]);
const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
const resolvedTimeDisplay =
timeDisplay ??
resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
return (
<div className="relative h-dvh overflow-hidden text-white">
<div
aria-hidden
className="absolute -inset-8 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
style={getSceneStageBackgroundStyle(selection.selectedScene, selection.selectedSceneAsset)}
style={getSceneStageBackgroundStyle(
selection.selectedScene,
selection.selectedSceneAsset,
)}
/>
<div className="relative z-10 flex h-full flex-col">
@@ -208,39 +251,47 @@ export const SpaceWorkspaceWidget = () => {
timerPresets={TIMER_SELECTION_PRESETS}
canStart={selection.canStart}
onSceneSelect={selection.handleSelectScene}
onTimerSelect={(timerLabel) => selection.handleSelectTimer(timerLabel, true)}
onSoundSelect={(presetId) => selection.handleSelectSound(presetId, true)}
onTimerSelect={(timerLabel) =>
selection.handleSelectTimer(timerLabel, true)
}
onSoundSelect={(presetId) =>
selection.handleSelectSound(presetId, true)
}
onGoalChange={selection.handleGoalChange}
onGoalChipSelect={selection.handleGoalChipSelect}
onStart={controls.handleSetupFocusOpen}
resumeHint={
selection.showResumePrompt && selection.resumeGoal
? {
goal: selection.resumeGoal,
onResume: () => {
selection.setGoalInput(selection.resumeGoal);
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
controls.openFocusMode(selection.resumeGoal, 'resume-restore');
},
onStartFresh: () => {
selection.setGoalInput('');
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
},
}
goal: selection.resumeGoal,
onResume: () => {
selection.setGoalInput(selection.resumeGoal);
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
controls.openFocusMode(
selection.resumeGoal,
"resume-restore",
);
},
onStartFresh: () => {
selection.setGoalInput("");
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
},
}
: undefined
}
/>
<SpaceFocusHudWidget
goal={selection.goalInput.trim()}
microStep={currentSession?.microStep ?? null}
timerLabel={selection.selectedTimerLabel}
timeDisplay={resolvedTimeDisplay}
visible={isFocusMode}
hasActiveSession={Boolean(currentSession)}
playbackState={resolvedPlaybackState}
sessionPhase={phase ?? 'focus'}
sessionPhase={phase ?? "focus"}
isSessionActionPending={isSessionMutating}
canStartSession={controls.canStartSession}
canPauseSession={controls.canPauseSession}
@@ -260,7 +311,7 @@ export const SpaceWorkspaceWidget = () => {
<FocusTopToast
visible={isFocusMode && Boolean(activeStatus)}
message={activeStatus?.message ?? ''}
message={activeStatus?.message ?? ""}
actionLabel={activeStatus?.action?.label}
onAction={runActiveAction}
/>
@@ -276,15 +327,25 @@ export const SpaceWorkspaceWidget = () => {
thoughtCount={thoughtCount}
selectedPresetId={selectedPresetId}
onSceneSelect={selection.handleSelectScene}
onTimerSelect={(timerLabel) => selection.handleSelectTimer(timerLabel, true)}
onQuickSoundSelect={(presetId) => selection.handleSelectSound(presetId, true)}
onTimerSelect={(timerLabel) =>
selection.handleSelectTimer(timerLabel, true)
}
onQuickSoundSelect={(presetId) =>
selection.handleSelectSound(presetId, true)
}
sceneRecommendedSoundLabel={selection.selectedScene.recommendedSound}
sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selection.selectedScene.recommendedTimerPresetId) ?? selection.selectedTimerLabel}
sceneRecommendedTimerLabel={
resolveTimerLabelFromPresetId(
selection.selectedScene.recommendedTimerPresetId,
) ?? selection.selectedTimerLabel
}
soundVolume={masterVolume}
onSetSoundVolume={setMasterVolume}
isSoundMuted={isMuted}
onSetSoundMuted={setMuted}
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}
onCaptureThought={(note) =>
addThought(note, selection.selectedScene.name)
}
onDeleteThought={removeThought}
onSetThoughtCompleted={setThoughtCompleted}
onRestoreThought={restoreThought}

View File

@@ -1,34 +1,8 @@
'use client';
import Link from 'next/link';
import { copy } from '@/shared/i18n';
import { useFocusStats } from '@/features/stats';
const StatSection = ({
title,
items,
}: {
title: string;
items: Array<{ id: string; label: string; value: string; delta: string }>;
}) => {
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{title}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{items.map((item) => (
<article
key={item.id}
className="rounded-xl border border-brand-dark/10 bg-white/80 p-4 backdrop-blur-sm"
>
<p className="text-xs text-brand-dark/58">{item.label}</p>
<p className="mt-2 text-xl font-semibold text-brand-dark">{item.value}</p>
<p className="mt-1 text-xs text-brand-primary/90">{item.delta}</p>
</article>
))}
</div>
</section>
);
};
import { copy } from '@/shared/i18n';
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, minutes);
@@ -42,55 +16,84 @@ const formatMinutes = (minutes: number) => {
return `${hourPart}h ${minutePart}m`;
};
const FactualStatCard = ({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) => {
return (
<article className="rounded-2xl border border-brand-dark/10 bg-white/82 p-4 backdrop-blur-sm">
<p className="text-xs text-brand-dark/58">{label}</p>
<p className="mt-2 text-xl font-semibold text-brand-dark">{value}</p>
<p className="mt-1 text-xs text-brand-dark/48">{hint}</p>
</article>
);
};
export const StatsOverviewWidget = () => {
const { stats } = copy;
const { summary, isLoading, error, source, refetch } = useFocusStats();
const todayItems = [
{
id: 'today-focus',
label: stats.todayFocus,
value: formatMinutes(summary.today.focusMinutes),
delta: source === 'api' ? stats.apiLabel : stats.mockLabel,
hint: source === 'api' ? stats.apiLabel : stats.mockLabel,
},
{
id: 'today-cycles',
id: 'today-complete',
label: stats.completedCycles,
value: `${summary.today.completedCycles}${stats.countUnit}`,
delta: `${summary.today.completedCycles > 0 ? '+' : ''}${summary.today.completedCycles}`,
hint: '오늘 완료한 focus cycle',
},
{
id: 'today-entry',
label: stats.sessionEntries,
value: `${summary.today.sessionEntries}${stats.countUnit}`,
delta: source === 'api' ? stats.syncedApi : stats.temporary,
hint: '오늘 space에 들어간 횟수',
},
];
const weeklyItems = [
{
id: 'week-focus',
label: stats.last7DaysFocus,
value: formatMinutes(summary.last7Days.focusMinutes),
delta: source === 'api' ? stats.actualAggregate : stats.mockAggregate,
hint: '최근 7일 총 focus 시간',
},
{
id: 'week-best-day',
label: stats.bestDay,
value: summary.last7Days.bestDayLabel,
delta: formatMinutes(summary.last7Days.bestDayFocusMinutes),
id: 'week-started',
label: '시작한 세션',
value: `${summary.last7Days.startedSessions}${stats.countUnit}`,
hint: '최근 7일 시작 횟수',
},
{
id: 'week-consistency',
label: stats.streak,
value: `${summary.last7Days.streakDays}${stats.dayUnit}`,
delta: summary.last7Days.streakDays > 0 ? stats.streakActive : stats.streakStart,
id: 'week-completed',
label: '완료한 세션',
value: `${summary.last7Days.completedSessions}${stats.countUnit}`,
hint: '최근 7일 goal/timer 완료 횟수',
},
{
id: 'week-carry',
label: '이월된 블록',
value: `${summary.last7Days.carriedOverCount}${stats.countUnit}`,
hint: '다음 날로 이어진 계획 수',
},
];
return (
<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.title}</h1>
<div className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 sm:px-6">
<header className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
<div>
<h1 className="text-xl font-semibold">{stats.title}</h1>
<p className="mt-1 text-xs text-brand-dark/56"> insight factual summary만 .</p>
</div>
<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"
@@ -100,7 +103,7 @@ export const StatsOverviewWidget = () => {
</header>
<div className="space-y-6">
<section className="rounded-xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
<section className="rounded-2xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-xs font-medium text-brand-dark/72">
@@ -126,12 +129,27 @@ export const StatsOverviewWidget = () => {
</div>
</section>
<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">{stats.today}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{todayItems.map((item) => (
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
))}
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{stats.last7Days}</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{weeklyItems.map((item) => (
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
))}
</div>
</section>
<section className="space-y-3">
<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="rounded-2xl 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 ? (
<div className="flex h-full items-end gap-2">
@@ -145,9 +163,7 @@ export const StatsOverviewWidget = () => {
style={{ height: `${barHeight}%` }}
title={stats.barTitle(point.date, point.focusMinutes)}
/>
<span className="text-[10px] text-brand-dark/56">
{point.date.slice(5)}
</span>
<span className="text-[10px] text-brand-dark/56">{point.date.slice(5)}</span>
</div>
);
})}
@@ -155,9 +171,7 @@ export const StatsOverviewWidget = () => {
) : null}
</div>
<p className="mt-3 text-xs text-brand-dark/56">
{summary.trend.length > 0
? stats.chartWithTrend
: stats.chartWithoutTrend}
{summary.trend.length > 0 ? stats.chartWithTrend : stats.chartWithoutTrend}
</p>
</div>
</section>