feat(core-loop): /app 진입과 /space 복구 흐름 구현
This commit is contained in:
@@ -1,566 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type FocusPlanItem,
|
||||
type FocusPlanToday,
|
||||
useFocusPlan,
|
||||
} from '@/entities/focus-plan';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { SCENE_THEMES, getSceneById } from '@/entities/scene';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { PlanPill } from '@/features/plan-pill';
|
||||
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useDragScroll } from '@/shared/lib/useDragScroll';
|
||||
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
|
||||
|
||||
const FREE_MAX_ITEMS = 1;
|
||||
const PRO_MAX_ITEMS = 5;
|
||||
const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
|
||||
const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id;
|
||||
const DEFAULT_TIMER_ID = '50-10';
|
||||
const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4);
|
||||
|
||||
const focusEntryCopy = {
|
||||
const entryCopy = {
|
||||
eyebrow: 'VibeRoom',
|
||||
title: '오늘의 깊은 몰입을 위한 단 하나의 목표',
|
||||
description: '지금 당장 시작할 딱 하나만 남겨두세요.',
|
||||
inputLabel: '첫 블록',
|
||||
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
helper: '아주 작게 잡아도 괜찮아요.',
|
||||
startNow: '바로 몰입하기',
|
||||
nextStep: '환경 세팅',
|
||||
manageBlocks: '내 계획에서 가져오기',
|
||||
previewTitle: '이어갈 블록',
|
||||
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
|
||||
reviewLinkLabel: 'stats',
|
||||
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
|
||||
ritualMeta: '기본 설정으로 들어갑니다. 공간 안에서 언제든 바꿀 수 있어요.',
|
||||
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
|
||||
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
||||
paywallSource: 'focus-entry-manage-sheet',
|
||||
title: '지금 붙잡을 한 가지',
|
||||
description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.',
|
||||
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
microStepLabel: '지금 할 한 조각',
|
||||
microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기',
|
||||
microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.',
|
||||
startNow: '지금 시작',
|
||||
startLoading: '몰입 준비 중...',
|
||||
ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds',
|
||||
ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.',
|
||||
resumeEyebrow: 'Resume',
|
||||
resumeRunning: '진행 중인 세션이 있어요.',
|
||||
resumePaused: '잠시 멈춘 세션이 있어요.',
|
||||
resumeCta: '이어서 들어가기',
|
||||
resumeMicroStepLabel: '마지막 한 조각',
|
||||
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||
paywallLead: 'Calm Session OS PRO',
|
||||
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
|
||||
microStepTitle: '가장 작은 첫 단계 (선택)',
|
||||
microStepHelper: '이 목표를 위해 당장 할 수 있는 5분짜리 행동은 무엇인가요?',
|
||||
microStepPlaceholder: '예: 폴더 열기, 노션 켜기',
|
||||
ritualTitle: '어떤 환경에서 몰입하시겠어요?',
|
||||
ritualHelper: '오늘의 무드를 선택하세요.',
|
||||
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
|
||||
};
|
||||
|
||||
const ENTRY_SUGGESTIONS = [
|
||||
{ id: 'tidy-10m', label: '정리 10분', goal: '정리 10분만 하기' },
|
||||
{ id: 'mail-3', label: '메일 3개', goal: '메일 3개 정리' },
|
||||
{ id: 'doc-1p', label: '문서 1p', goal: '문서 1p 다듬기' },
|
||||
] as const;
|
||||
const goalCardClass =
|
||||
'w-full rounded-[2rem] border border-white/12 bg-[#0f1115]/26 px-6 py-6 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl md:px-8 md:py-8';
|
||||
const inputShellClass =
|
||||
'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
|
||||
const primaryButtonClass =
|
||||
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
|
||||
|
||||
type EntrySource = 'starter' | 'plan' | 'custom';
|
||||
type DashboardStep = 'goal' | 'ritual';
|
||||
|
||||
const getVisiblePlanItems = (
|
||||
currentItem: FocusPlanItem | null,
|
||||
nextItems: FocusPlanItem[],
|
||||
limit: number,
|
||||
) => {
|
||||
return [currentItem, ...nextItems]
|
||||
.filter((item): item is FocusPlanItem => Boolean(item))
|
||||
.slice(0, limit);
|
||||
const timerLabelById: Record<string, string> = {
|
||||
'25-5': '25/5',
|
||||
'50-10': '50/10',
|
||||
'90-20': '90/20',
|
||||
};
|
||||
|
||||
const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => {
|
||||
return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit);
|
||||
};
|
||||
const resolveSoundLabel = (soundPresetId?: string | null) => {
|
||||
if (!soundPresetId) {
|
||||
return 'Silent';
|
||||
}
|
||||
|
||||
// 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)]';
|
||||
return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent';
|
||||
};
|
||||
|
||||
export const FocusDashboardWidget = () => {
|
||||
const router = useRouter();
|
||||
const { plan: planTier, isPro, setPlan } = usePlanTier();
|
||||
const { plan, isLoading, isSaving, source, createItem, updateItem, deleteItem } = useFocusPlan();
|
||||
const { plan, isPro, setPlan } = usePlanTier();
|
||||
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 [goalDraft, setGoalDraft] = useState('');
|
||||
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 [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
|
||||
|
||||
const entryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const microStepInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const {
|
||||
containerRef: sceneContainerRef,
|
||||
events: sceneDragEvents,
|
||||
isDragging: isSceneDragging,
|
||||
shouldSuppressClick: shouldSuppressSceneClick,
|
||||
} = useDragScroll();
|
||||
const activeScene = useMemo(() => {
|
||||
return getSceneById(currentSession?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
|
||||
}, [currentSession?.sceneId]);
|
||||
|
||||
const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]);
|
||||
const activeRitualMeta = useMemo(() => {
|
||||
const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10';
|
||||
const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID);
|
||||
|
||||
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
|
||||
const planItems = useMemo(() => {
|
||||
return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems);
|
||||
}, [maxItems, plan.currentItem, plan.nextItems]);
|
||||
return `${activeScene.name} · ${timerLabel} · ${soundLabel}`;
|
||||
}, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]);
|
||||
|
||||
const currentItem = planItems[0] ?? null;
|
||||
const shouldUseCurrentPlanDefaults =
|
||||
Boolean(currentItem) && (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId));
|
||||
const resolvedEntryDraft = shouldUseCurrentPlanDefaults && currentItem ? currentItem.title : entryDraft;
|
||||
const resolvedSelectedPlanItemId =
|
||||
shouldUseCurrentPlanDefaults && currentItem ? currentItem.id : selectedPlanItemId;
|
||||
|
||||
const hasPendingEdit = editingState !== null;
|
||||
const canAddMore = planItems.length < maxItems;
|
||||
const canManagePlan = source === 'api' && !isLoading;
|
||||
const trimmedEntryGoal = resolvedEntryDraft.trim();
|
||||
const isGoalReady = trimmedEntryGoal.length > 0;
|
||||
const trimmedGoal = goalDraft.trim();
|
||||
const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingState) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
}, [editingState]);
|
||||
let cancelled = false;
|
||||
|
||||
const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource);
|
||||
const loadCurrentSession = async () => {
|
||||
setIsCheckingSession(true);
|
||||
|
||||
const handleSelectPlanItem = (item: FocusPlanItem) => {
|
||||
const isCurrentSelection = currentItem?.id === item.id;
|
||||
setEntryDraft(item.title);
|
||||
setSelectedPlanItemId(isCurrentSelection ? item.id : null);
|
||||
setEntrySource(isCurrentSelection ? 'plan' : 'custom');
|
||||
setManageSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (goal: string) => {
|
||||
setEntryDraft(goal);
|
||||
setSelectedPlanItemId(null);
|
||||
setEntrySource('custom');
|
||||
};
|
||||
|
||||
const handleEntryDraftChange = (value: string) => {
|
||||
setEntryDraft(value);
|
||||
setEntrySource('custom');
|
||||
setSelectedPlanItemId(null);
|
||||
};
|
||||
|
||||
const handleAddBlock = () => {
|
||||
if (hasPendingEdit || isSaving || !canManagePlan) return;
|
||||
if (!canAddMore) {
|
||||
if (!isPro) openPaywall();
|
||||
return;
|
||||
}
|
||||
setEditingState({ mode: 'new', value: '' });
|
||||
};
|
||||
|
||||
const handleEditRow = (item: FocusPlanItem) => {
|
||||
if (hasPendingEdit || isSaving) return;
|
||||
setEditingState({ mode: 'edit', itemId: item.id, value: item.title });
|
||||
};
|
||||
|
||||
const handleManageDraftChange = (value: string) => {
|
||||
setEditingState((current) => current ? { ...current, value } : current);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (!isSaving) setEditingState(null);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingState) return;
|
||||
const trimmedTitle = editingState.value.trim();
|
||||
if (!trimmedTitle) return;
|
||||
|
||||
if (editingState.mode === 'new') {
|
||||
const nextPlan = await createItem({ title: trimmedTitle });
|
||||
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');
|
||||
try {
|
||||
const session = await focusSessionApi.getCurrentSession();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setCurrentSession(session);
|
||||
setSessionLookupError(null);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setCurrentSession(null);
|
||||
setSessionLookupError(error instanceof Error ? error.message : entryCopy.loadFailed);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsCheckingSession(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const currentRow = planItems.find((item) => item.id === editingState.itemId);
|
||||
if (!currentRow) return;
|
||||
if (currentRow.title === trimmedTitle) {
|
||||
setEditingState(null);
|
||||
return;
|
||||
}
|
||||
void loadCurrentSession();
|
||||
|
||||
const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
|
||||
if (!nextPlan) return;
|
||||
setEditingState(null);
|
||||
if (resolvedSelectedPlanItemId === editingState.itemId) {
|
||||
setEntryDraft(trimmedTitle);
|
||||
setEntrySource('plan');
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPaywall = () => {
|
||||
if (!isPro) {
|
||||
setPaywallSource('app-entry-plan-pill');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRow = async (itemId: string) => {
|
||||
const nextPlan = await deleteItem(itemId);
|
||||
if (!nextPlan) return;
|
||||
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
|
||||
setEditingState(null);
|
||||
}
|
||||
if (resolvedSelectedPlanItemId === 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 handleSelectSuggestion = (label: string) => {
|
||||
setGoalDraft(label);
|
||||
goalInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleStartSession = async () => {
|
||||
if (isStartingSession) return;
|
||||
if (!trimmedGoal || isStartingSession || currentSession) {
|
||||
if (!trimmedGoal) {
|
||||
goalInputRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStartingSession(true);
|
||||
|
||||
try {
|
||||
await focusSessionApi.startSession({
|
||||
goal: trimmedEntryGoal,
|
||||
goal: trimmedGoal,
|
||||
microStep: microStepDraft.trim() || null,
|
||||
sceneId: selectedSceneId,
|
||||
soundPresetId: selectedSoundId,
|
||||
timerPresetId: selectedTimerId,
|
||||
focusPlanItemId: resolvedSelectedPlanItemId || undefined,
|
||||
entryPoint: 'space-setup'
|
||||
sceneId: DEFAULT_SCENE_ID,
|
||||
soundPresetId: DEFAULT_SOUND_ID,
|
||||
timerPresetId: DEFAULT_TIMER_ID,
|
||||
entryPoint: 'space-setup',
|
||||
});
|
||||
router.push('/space');
|
||||
} catch (err) {
|
||||
console.error('Failed to start session', err);
|
||||
setIsStartingSession(false);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to start focus session from /app', error);
|
||||
}
|
||||
|
||||
setIsStartingSession(false);
|
||||
};
|
||||
|
||||
const handleResumeSession = () => {
|
||||
router.push('/space');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-dvh overflow-hidden bg-slate-900 text-white font-sans selection:bg-white/20">
|
||||
{/* Premium Cinematic Background */}
|
||||
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
||||
<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' : ''
|
||||
'absolute inset-0 bg-cover bg-center transition-transform duration-700 ease-out',
|
||||
isStartingSession ? 'scale-[1.04]' : 'scale-100',
|
||||
)}
|
||||
style={getSceneStageBackgroundStyle(selectedScene, sceneAssetMap?.[selectedScene.id])}
|
||||
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.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'
|
||||
)} />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.38)_55%,rgba(2,6,23,0.5)_100%)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.06),rgba(255,255,255,0)_42%)]" />
|
||||
|
||||
{/* 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}
|
||||
<header className="relative z-10 flex items-center justify-between px-5 py-5 md:px-8 md:py-7">
|
||||
<p className="text-sm font-semibold tracking-[0.28em] text-white/56 uppercase">
|
||||
{entryCopy.eyebrow}
|
||||
</p>
|
||||
<PlanPill
|
||||
plan={planTier}
|
||||
onClick={() => {
|
||||
if (!isPro) openPaywall();
|
||||
}}
|
||||
/>
|
||||
<PlanPill plan={plan} onClick={openPaywall} />
|
||||
</header>
|
||||
|
||||
{/* 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={resolvedEntryDraft}
|
||||
onChange={(event) => handleEntryDraftChange(event.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
|
||||
placeholder={focusEntryCopy.inputPlaceholder}
|
||||
className={glassInputClass}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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 = resolvedSelectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSuggestion(suggestion.goal)}
|
||||
className={cn(
|
||||
'rounded-full px-4 py-1.5 text-sm transition-all border',
|
||||
isActive
|
||||
? 'bg-white/20 border-white text-white'
|
||||
: 'bg-transparent border-white/20 text-white/70 hover:border-white/40 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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>
|
||||
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-center justify-center px-4 pb-8 pt-4 md:px-6">
|
||||
<div className="w-full max-w-[42rem]">
|
||||
{isCheckingSession ? (
|
||||
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||
{entryCopy.resumeEyebrow}
|
||||
</p>
|
||||
<p className="text-[15px] text-white/72">세션 상태를 불러오는 중이에요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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="mb-2 text-xs uppercase tracking-widest text-white/40">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"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
) : currentSession ? (
|
||||
<div className={cn(goalCardClass, 'space-y-5')}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||
{entryCopy.resumeEyebrow}
|
||||
</p>
|
||||
<h1 className="text-[1.8rem] font-light leading-[1.14] tracking-[-0.03em] text-white md:text-[2.2rem]">
|
||||
{currentSession.goal}
|
||||
</h1>
|
||||
<p className="text-sm text-white/68">
|
||||
{currentSession.state === 'paused' ? entryCopy.resumePaused : entryCopy.resumeRunning}
|
||||
</p>
|
||||
{currentSession.microStep ? (
|
||||
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-white/44">
|
||||
{entryCopy.resumeMicroStepLabel}
|
||||
</p>
|
||||
<p className="text-[15px] text-white/82">{currentSession.microStep}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Scene */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">배경 공간</p>
|
||||
<div
|
||||
ref={sceneContainerRef}
|
||||
{...sceneDragEvents}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
|
||||
{entryCopy.resumeCta}
|
||||
</button>
|
||||
<p className="text-xs text-white/48 sm:text-right">{activeRitualMeta}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-white/56">{entryCopy.resumeNewGoalHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={goalCardClass}>
|
||||
<div className="space-y-3 text-center">
|
||||
<h1 className="text-[2rem] font-light leading-[1.08] tracking-[-0.04em] text-white md:text-[2.9rem]">
|
||||
{entryCopy.title}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[32rem] text-[15px] leading-6 text-white/70 md:text-base">
|
||||
{entryCopy.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<label className="block">
|
||||
<span className="sr-only">Goal</span>
|
||||
<input
|
||||
ref={goalInputRef}
|
||||
value={goalDraft}
|
||||
onChange={(event) => setGoalDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleStartSession();
|
||||
}
|
||||
}}
|
||||
placeholder={entryCopy.goalPlaceholder}
|
||||
className={cn(
|
||||
"flex gap-3 overflow-x-auto pb-2 scrollbar-none",
|
||||
isSceneDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
inputShellClass,
|
||||
'text-[1.15rem] font-light tracking-[-0.02em] placeholder:text-white/34 md:text-[1.4rem]',
|
||||
)}
|
||||
>
|
||||
{SCENE_THEMES.map(scene => (
|
||||
<button
|
||||
key={scene.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!shouldSuppressSceneClick) {
|
||||
setSelectedSceneId(scene.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left transition-all overflow-hidden bg-white/5 active:scale-95',
|
||||
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]',
|
||||
isSceneDragging && 'pointer-events-none'
|
||||
)}
|
||||
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>
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 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>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
|
||||
{entryCopy.microStepLabel}
|
||||
</span>
|
||||
<input
|
||||
value={microStepDraft}
|
||||
onChange={(event) => setMicroStepDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleStartSession();
|
||||
}
|
||||
}}
|
||||
placeholder={entryCopy.microStepPlaceholder}
|
||||
className={cn(inputShellClass, 'text-[0.98rem] placeholder:text-white/30')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="text-sm text-white/48">{entryCopy.microStepHelper}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2.5">
|
||||
{GOAL_SUGGESTIONS.map((suggestion) => {
|
||||
const isActive = trimmedGoal === suggestion.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSuggestion(suggestion.label)}
|
||||
className={cn(
|
||||
'rounded-full border px-3.5 py-1.5 text-sm transition',
|
||||
isActive
|
||||
? 'border-white/32 bg-white/14 text-white'
|
||||
: 'border-white/14 bg-white/[0.04] text-white/72 hover:border-white/22 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleStartSession();
|
||||
}}
|
||||
disabled={!canStart}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{isStartingSession ? entryCopy.startLoading : entryCopy.startNow}
|
||||
</button>
|
||||
<div className="space-y-1 text-left sm:text-right">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-white/44">
|
||||
{entryCopy.ritualHint}
|
||||
</p>
|
||||
<p className="text-xs text-white/52">{entryCopy.ritualHelper}</p>
|
||||
</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>
|
||||
{sessionLookupError ? (
|
||||
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Plan Sheet & Paywall */}
|
||||
<FocusPlanManageSheet
|
||||
isOpen={manageSheetOpen}
|
||||
planItems={planItems}
|
||||
selectedPlanItemId={selectedPlanItemId}
|
||||
editingState={editingState}
|
||||
isSaving={isSaving}
|
||||
canAddMore={canAddMore}
|
||||
isPro={isPro}
|
||||
inputRef={inputRef}
|
||||
onClose={() => {
|
||||
if (!isSaving) {
|
||||
setManageSheetOpen(false);
|
||||
setEditingState(null);
|
||||
}
|
||||
}}
|
||||
onAddBlock={handleAddBlock}
|
||||
onDraftChange={handleManageDraftChange}
|
||||
onSelect={handleSelectPlanItem}
|
||||
onEdit={handleEditRow}
|
||||
onDelete={(itemId) => {
|
||||
void handleDeleteRow(itemId);
|
||||
}}
|
||||
onSave={() => {
|
||||
void handleSaveEdit();
|
||||
}}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
|
||||
{paywallSource ? (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copy.modal.closeAriaLabel}
|
||||
onClick={() => setPaywallSource(null)}
|
||||
className="absolute inset-0 bg-slate-950/48 backdrop-blur-[2px]"
|
||||
className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]"
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-md rounded-3xl border border-white/12 bg-[linear-gradient(165deg,rgba(15,23,42,0.94)_0%,rgba(2,6,23,0.98)_100%)] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.36)]">
|
||||
<p className="mb-3 text-[11px] uppercase tracking-[0.16em] text-white/42">
|
||||
{focusEntryCopy.paywallLead}
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-white/62">
|
||||
{focusEntryCopy.paywallBody}
|
||||
{entryCopy.paywallLead}
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-white/62">{entryCopy.paywallBody}</p>
|
||||
<PaywallSheetContent
|
||||
onStartPro={() => {
|
||||
setPlan('pro');
|
||||
|
||||
@@ -4,11 +4,24 @@ import type { FormEvent } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import {
|
||||
HUD_FIELD,
|
||||
HUD_OPTION_CHEVRON,
|
||||
HUD_OPTION_ROW,
|
||||
HUD_OPTION_ROW_PRIMARY,
|
||||
HUD_TEXT_LINK,
|
||||
HUD_TEXT_LINK_STRONG,
|
||||
HUD_TRAY_HAIRLINE,
|
||||
HUD_TRAY_LAYER,
|
||||
HUD_TRAY_SHELL,
|
||||
} from './overlayStyles';
|
||||
|
||||
interface GoalCompleteSheetProps {
|
||||
open: boolean;
|
||||
currentGoal: string;
|
||||
preferredView?: 'choice' | 'next';
|
||||
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
|
||||
onFinish: () => Promise<boolean> | boolean;
|
||||
onRest: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -16,18 +29,22 @@ interface GoalCompleteSheetProps {
|
||||
export const GoalCompleteSheet = ({
|
||||
open,
|
||||
currentGoal,
|
||||
preferredView = 'choice',
|
||||
onConfirm,
|
||||
onFinish,
|
||||
onRest,
|
||||
onClose,
|
||||
}: GoalCompleteSheetProps) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | null>(null);
|
||||
const [view, setView] = useState<'choice' | 'next'>('choice');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDraft('');
|
||||
setView(preferredView);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
@@ -35,6 +52,10 @@ export const GoalCompleteSheet = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (view !== 'next') {
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
@@ -42,7 +63,15 @@ export const GoalCompleteSheet = ({
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, preferredView, view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setView(preferredView);
|
||||
}, [open, preferredView]);
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
const trimmed = currentGoal.trim();
|
||||
@@ -55,6 +84,9 @@ export const GoalCompleteSheet = ({
|
||||
}, [currentGoal]);
|
||||
|
||||
const canConfirm = draft.trim().length > 0;
|
||||
const isSubmitting = submissionMode !== null;
|
||||
const trimmedCurrentGoal = currentGoal.trim();
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -62,7 +94,7 @@ export const GoalCompleteSheet = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmissionMode('next');
|
||||
|
||||
try {
|
||||
const didAdvance = await onConfirm(draft.trim());
|
||||
@@ -71,7 +103,25 @@ export const GoalCompleteSheet = ({
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setSubmissionMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmissionMode('finish');
|
||||
|
||||
try {
|
||||
const didFinish = await onFinish();
|
||||
|
||||
if (didFinish) {
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setSubmissionMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,12 +135,9 @@ export const GoalCompleteSheet = ({
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<section className="pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#0f1115]/28 px-5 py-4 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]"
|
||||
/>
|
||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16" />
|
||||
<section className={HUD_TRAY_SHELL}>
|
||||
<div aria-hidden className={HUD_TRAY_LAYER} />
|
||||
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
|
||||
|
||||
<header className="relative flex items-start justify-between gap-2">
|
||||
<div>
|
||||
@@ -109,32 +156,115 @@ export const GoalCompleteSheet = ({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="relative mt-3 space-y-3" onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/30 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8"
|
||||
/>
|
||||
<footer className="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRest}
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-white/10 bg-black/14 px-3 text-[11px] font-medium tracking-[0.14em] text-white/62 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white/84 disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
|
||||
>
|
||||
{copy.space.goalComplete.restButton}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canConfirm || isSubmitting}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-white/12 bg-black/18 px-3.5 text-[11px] font-semibold tracking-[0.16em] text-white/88 backdrop-blur-md transition-all hover:bg-black/24 hover:text-white disabled:cursor-not-allowed disabled:border-white/8 disabled:bg-black/10 disabled:text-white/30"
|
||||
>
|
||||
{isSubmitting ? copy.space.goalComplete.confirmPending : copy.space.goalComplete.confirmButton}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
{view === 'choice' ? (
|
||||
<div className="relative mt-3 space-y-3">
|
||||
{trimmedCurrentGoal ? (
|
||||
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
|
||||
{copy.space.goalComplete.currentGoalLabel}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[14px] text-white/86">{trimmedCurrentGoal}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer className="mt-4 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFinish}
|
||||
disabled={isSubmitting}
|
||||
className={HUD_OPTION_ROW}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
|
||||
{submissionMode === 'finish'
|
||||
? copy.space.goalComplete.finishPending
|
||||
: copy.space.goalComplete.finishButton}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/44">
|
||||
{copy.space.goalComplete.finishDescription}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRest}
|
||||
disabled={isSubmitting}
|
||||
className={HUD_OPTION_ROW}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
|
||||
{copy.space.goalComplete.restButton}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/44">
|
||||
{copy.space.goalComplete.restDescription}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('next')}
|
||||
disabled={isSubmitting}
|
||||
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
|
||||
{copy.space.goalComplete.chooseNextButton}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/48">
|
||||
{copy.space.goalComplete.chooseNextDescription}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
) : (
|
||||
<form className="relative mt-3 space-y-3" onSubmit={handleSubmit}>
|
||||
{trimmedCurrentGoal ? (
|
||||
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
|
||||
{copy.space.goalComplete.currentGoalLabel}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[14px] text-white/86">{trimmedCurrentGoal}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
|
||||
{copy.space.goalComplete.nextGoalLabel}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={HUD_FIELD}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<footer className="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('choice')}
|
||||
disabled={isSubmitting}
|
||||
className={HUD_TEXT_LINK}
|
||||
>
|
||||
{copy.space.goalComplete.backButton}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canConfirm || isSubmitting}
|
||||
className={HUD_TEXT_LINK_STRONG}
|
||||
>
|
||||
{submissionMode === 'next'
|
||||
? copy.space.goalComplete.confirmPending
|
||||
: copy.space.goalComplete.confirmButton}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import {
|
||||
HUD_OPTION_CHEVRON,
|
||||
HUD_OPTION_ROW,
|
||||
HUD_OPTION_ROW_PRIMARY,
|
||||
HUD_TRAY_HAIRLINE,
|
||||
HUD_TRAY_LAYER,
|
||||
HUD_TRAY_SHELL,
|
||||
} from './overlayStyles';
|
||||
|
||||
interface NextMicroStepPromptProps {
|
||||
open: boolean;
|
||||
goal: string;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
onKeepGoalOnly: () => void;
|
||||
@@ -13,11 +22,14 @@ interface NextMicroStepPromptProps {
|
||||
|
||||
export const NextMicroStepPrompt = ({
|
||||
open,
|
||||
goal,
|
||||
isSubmitting,
|
||||
error,
|
||||
onKeepGoalOnly,
|
||||
onDefineNext,
|
||||
}: NextMicroStepPromptProps) => {
|
||||
const trimmedGoal = goal.trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -28,12 +40,9 @@ export const NextMicroStepPrompt = ({
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<section className="pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#0f1115]/28 px-5 py-4 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]"
|
||||
/>
|
||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16" />
|
||||
<section className={HUD_TRAY_SHELL}>
|
||||
<div aria-hidden className={HUD_TRAY_LAYER} />
|
||||
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
|
||||
|
||||
<div className="relative w-full">
|
||||
<p className="text-[11px] font-medium tracking-[0.08em] text-white/42">다음 한 조각</p>
|
||||
@@ -44,28 +53,53 @@ export const NextMicroStepPrompt = ({
|
||||
{copy.space.focusHud.microStepPromptDescription}
|
||||
</p>
|
||||
|
||||
{trimmedGoal ? (
|
||||
<div className="mt-3 rounded-[16px] border border-white/8 bg-black/10 px-3.5 py-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
|
||||
{copy.space.focusHud.intentLabel}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[14px] text-white/84">{trimmedGoal}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="mt-3 text-[12px] text-rose-100/86">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<div className="mt-4 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onKeepGoalOnly}
|
||||
disabled={isSubmitting}
|
||||
className="text-[12px] font-medium tracking-[0.08em] text-white/62 underline decoration-white/16 underline-offset-4 transition-all duration-200 hover:text-white/86 hover:decoration-white/28 disabled:cursor-default disabled:text-white/26 disabled:no-underline"
|
||||
className={HUD_OPTION_ROW}
|
||||
>
|
||||
{copy.space.focusHud.microStepPromptKeep}
|
||||
<div>
|
||||
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
|
||||
{copy.space.focusHud.microStepPromptKeep}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/44">
|
||||
{copy.space.focusHud.microStepPromptKeepHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDefineNext}
|
||||
disabled={isSubmitting}
|
||||
className="text-[12px] font-semibold tracking-[0.08em] text-white/86 underline decoration-white/22 underline-offset-4 transition-all duration-200 hover:text-white hover:decoration-white/36 disabled:cursor-default disabled:text-white/30 disabled:no-underline"
|
||||
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
|
||||
>
|
||||
{copy.space.focusHud.microStepPromptDefine}
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
|
||||
{copy.space.focusHud.microStepPromptDefine}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/48">
|
||||
{copy.space.focusHud.microStepPromptDefineHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
93
src/widgets/space-focus-hud/ui/PauseRefocusPrompt.tsx
Normal file
93
src/widgets/space-focus-hud/ui/PauseRefocusPrompt.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import {
|
||||
HUD_OPTION_CHEVRON,
|
||||
HUD_OPTION_ROW,
|
||||
HUD_OPTION_ROW_PRIMARY,
|
||||
HUD_PAUSE_BODY,
|
||||
HUD_PAUSE_EYEBROW,
|
||||
HUD_PAUSE_TITLE,
|
||||
HUD_TRAY_HAIRLINE,
|
||||
HUD_TRAY_LAYER,
|
||||
HUD_TRAY_SHELL,
|
||||
} from './overlayStyles';
|
||||
|
||||
interface PauseRefocusPromptProps {
|
||||
open: boolean;
|
||||
isBusy: boolean;
|
||||
onRefocus: () => void;
|
||||
onKeepCurrent: () => void;
|
||||
}
|
||||
|
||||
export const PauseRefocusPrompt = ({
|
||||
open,
|
||||
isBusy,
|
||||
onRefocus,
|
||||
onKeepCurrent,
|
||||
}: PauseRefocusPromptProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-0',
|
||||
open
|
||||
? 'max-h-[24rem] translate-y-0 opacity-100'
|
||||
: 'pointer-events-none max-h-0 -translate-y-2 opacity-0',
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<section className={HUD_TRAY_SHELL}>
|
||||
<div aria-hidden className={HUD_TRAY_LAYER} />
|
||||
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
|
||||
|
||||
<div className="relative px-6 py-5 md:px-6 md:py-5">
|
||||
<p className={HUD_PAUSE_EYEBROW}>
|
||||
{copy.space.focusHud.pausePromptEyebrow}
|
||||
</p>
|
||||
<h3 className={HUD_PAUSE_TITLE}>
|
||||
{copy.space.focusHud.pausePromptTitle}
|
||||
</h3>
|
||||
<p className={HUD_PAUSE_BODY}>
|
||||
{copy.space.focusHud.pausePromptDescription}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-2.5 border-t border-white/8 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefocus}
|
||||
disabled={isBusy}
|
||||
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-semibold leading-[1.35] tracking-[-0.01em] text-white/92">
|
||||
{copy.space.focusHud.pausePromptRefocus}
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-[20rem] text-[12px] leading-[1.5] text-white/50">
|
||||
{copy.space.focusHud.pausePromptRefocusHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-1')}>→</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onKeepCurrent}
|
||||
disabled={isBusy}
|
||||
className={HUD_OPTION_ROW}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-medium leading-[1.35] tracking-[-0.01em] text-white/82">
|
||||
{copy.space.focusHud.pausePromptKeep}
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-[20rem] text-[12px] leading-[1.5] text-white/46">
|
||||
{copy.space.focusHud.pausePromptKeepHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-1')}>→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,12 +4,21 @@ import type { FormEvent } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import {
|
||||
HUD_FIELD,
|
||||
HUD_TEXT_LINK,
|
||||
HUD_TEXT_LINK_STRONG,
|
||||
HUD_TRAY_HAIRLINE,
|
||||
HUD_TRAY_LAYER,
|
||||
HUD_TRAY_SHELL,
|
||||
} from './overlayStyles';
|
||||
|
||||
interface RefocusSheetProps {
|
||||
open: boolean;
|
||||
goalDraft: string;
|
||||
microStepDraft: string;
|
||||
autoFocusField: 'goal' | 'microStep';
|
||||
submitLabel?: string;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
onGoalChange: (value: string) => void;
|
||||
@@ -23,6 +32,7 @@ export const RefocusSheet = ({
|
||||
goalDraft,
|
||||
microStepDraft,
|
||||
autoFocusField,
|
||||
submitLabel,
|
||||
isSaving,
|
||||
error,
|
||||
onGoalChange,
|
||||
@@ -91,12 +101,9 @@ export const RefocusSheet = ({
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<section className="pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#0f1115]/28 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]"
|
||||
/>
|
||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16" />
|
||||
<section className={HUD_TRAY_SHELL}>
|
||||
<div aria-hidden className={HUD_TRAY_LAYER} />
|
||||
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
|
||||
|
||||
<header className="relative px-5 pt-4">
|
||||
<div className="min-w-0">
|
||||
@@ -118,7 +125,7 @@ export const RefocusSheet = ({
|
||||
value={goalDraft}
|
||||
onChange={(event) => onGoalChange(event.target.value)}
|
||||
placeholder={copy.space.sessionGoal.placeholder}
|
||||
className="h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[1rem] tracking-tight text-white placeholder:text-white/28 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8"
|
||||
className={HUD_FIELD}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -131,7 +138,7 @@ export const RefocusSheet = ({
|
||||
value={microStepDraft}
|
||||
onChange={(event) => onMicroStepChange(event.target.value)}
|
||||
placeholder={copy.space.sessionGoal.hint}
|
||||
className="h-11 w-full rounded-[18px] border border-white/10 bg-black/12 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/26 focus:border-white/20 focus:bg-black/18 focus:outline-none focus:ring-2 focus:ring-white/8"
|
||||
className={HUD_FIELD}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -146,16 +153,16 @@ export const RefocusSheet = ({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-white/10 bg-black/14 px-3 text-[11px] font-medium tracking-[0.14em] text-white/62 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white/84 disabled:cursor-default disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
|
||||
className={HUD_TEXT_LINK}
|
||||
>
|
||||
{copy.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || goalDraft.trim().length === 0}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-white/12 bg-black/18 px-3 text-[11px] font-semibold tracking-[0.16em] text-white/84 backdrop-blur-md transition-all hover:bg-black/24 hover:text-white disabled:cursor-not-allowed disabled:border-white/8 disabled:bg-black/10 disabled:text-white/30"
|
||||
className={HUD_TEXT_LINK_STRONG}
|
||||
>
|
||||
{isSaving ? copy.space.focusHud.refocusApplying : copy.space.focusHud.refocusApply}
|
||||
{isSaving ? copy.space.focusHud.refocusApplying : submitLabel ?? copy.space.focusHud.refocusApply}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
157
src/widgets/space-focus-hud/ui/ReturnPrompt.tsx
Normal file
157
src/widgets/space-focus-hud/ui/ReturnPrompt.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import {
|
||||
HUD_OPTION_CHEVRON,
|
||||
HUD_OPTION_ROW,
|
||||
HUD_OPTION_ROW_PRIMARY,
|
||||
HUD_TRAY_HAIRLINE,
|
||||
HUD_TRAY_LAYER,
|
||||
HUD_TRAY_SHELL,
|
||||
} from './overlayStyles';
|
||||
|
||||
interface ReturnPromptProps {
|
||||
open: boolean;
|
||||
mode: 'focus' | 'break';
|
||||
isBusy: boolean;
|
||||
onContinue: () => void;
|
||||
onRefocus: () => void;
|
||||
onRest?: () => void;
|
||||
onNextGoal?: () => void;
|
||||
}
|
||||
|
||||
export const ReturnPrompt = ({
|
||||
open,
|
||||
mode,
|
||||
isBusy,
|
||||
onContinue,
|
||||
onRefocus,
|
||||
onRest,
|
||||
onNextGoal,
|
||||
}: ReturnPromptProps) => {
|
||||
const isBreakReturn = mode === 'break';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-0',
|
||||
open
|
||||
? 'max-h-[22rem] translate-y-0 opacity-100'
|
||||
: 'pointer-events-none max-h-0 -translate-y-2 opacity-0',
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<section className={HUD_TRAY_SHELL}>
|
||||
<div aria-hidden className={HUD_TRAY_LAYER} />
|
||||
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
|
||||
|
||||
<div className="relative">
|
||||
<p className="text-[11px] font-medium tracking-[0.08em] text-white/42">
|
||||
{copy.space.focusHud.returnPromptEyebrow}
|
||||
</p>
|
||||
<h3 className="mt-1 text-[1rem] font-medium tracking-tight text-white/94">
|
||||
{isBreakReturn
|
||||
? copy.space.focusHud.returnPromptBreakTitle
|
||||
: copy.space.focusHud.returnPromptFocusTitle}
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] text-white/58">
|
||||
{isBreakReturn
|
||||
? copy.space.focusHud.returnPromptBreakDescription
|
||||
: copy.space.focusHud.returnPromptFocusDescription}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{isBreakReturn ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRest}
|
||||
disabled={isBusy}
|
||||
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
|
||||
{copy.space.focusHud.returnPromptRest}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/48">
|
||||
{copy.space.focusHud.returnPromptRestHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextGoal}
|
||||
disabled={isBusy}
|
||||
className={HUD_OPTION_ROW}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
|
||||
{copy.space.focusHud.returnPromptNext}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/44">
|
||||
{copy.space.focusHud.returnPromptNextHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefocus}
|
||||
disabled={isBusy}
|
||||
className={HUD_OPTION_ROW}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
|
||||
{copy.space.focusHud.returnPromptRefocus}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/44">
|
||||
{copy.space.focusHud.returnPromptRefocusHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
disabled={isBusy}
|
||||
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
|
||||
{copy.space.focusHud.returnPromptContinue}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/48">
|
||||
{copy.space.focusHud.returnPromptContinueHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefocus}
|
||||
disabled={isBusy}
|
||||
className={HUD_OPTION_ROW}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
|
||||
{copy.space.focusHud.returnPromptRefocus}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/44">
|
||||
{copy.space.focusHud.returnPromptRefocusHint}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden className={HUD_OPTION_CHEVRON}>→</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,9 @@ import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
||||
import { IntentCapsule } from './IntentCapsule';
|
||||
import { NextMicroStepPrompt } from './NextMicroStepPrompt';
|
||||
import { PauseRefocusPrompt } from './PauseRefocusPrompt';
|
||||
import { RefocusSheet } from './RefocusSheet';
|
||||
import { ReturnPrompt } from './ReturnPrompt';
|
||||
|
||||
interface SpaceFocusHudWidgetProps {
|
||||
goal: string;
|
||||
@@ -18,11 +20,14 @@ interface SpaceFocusHudWidgetProps {
|
||||
canStartSession?: boolean;
|
||||
canPauseSession?: boolean;
|
||||
canRestartSession?: boolean;
|
||||
returnPromptMode?: 'focus' | 'break' | null;
|
||||
onStartRequested?: () => void;
|
||||
onPauseRequested?: () => void;
|
||||
onRestartRequested?: () => void;
|
||||
onDismissReturnPrompt?: () => void;
|
||||
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
|
||||
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
|
||||
onGoalFinish: () => boolean | Promise<boolean>;
|
||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||
}
|
||||
|
||||
@@ -37,16 +42,19 @@ export const SpaceFocusHudWidget = ({
|
||||
canStartSession = false,
|
||||
canPauseSession = false,
|
||||
canRestartSession = false,
|
||||
returnPromptMode = null,
|
||||
onStartRequested,
|
||||
onPauseRequested,
|
||||
onRestartRequested,
|
||||
onDismissReturnPrompt,
|
||||
onIntentUpdate,
|
||||
onGoalUpdate,
|
||||
onGoalFinish,
|
||||
onStatusMessage,
|
||||
}: SpaceFocusHudWidgetProps) => {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [isRefocusOpen, setRefocusOpen] = useState(false);
|
||||
const [isMicroStepPromptOpen, setMicroStepPromptOpen] = useState(false);
|
||||
const [overlay, setOverlay] = useState<'none' | 'paused' | 'return' | 'refocus' | 'next-beat' | 'complete'>('none');
|
||||
const [refocusOrigin, setRefocusOrigin] = useState<'manual' | 'pause' | 'next-beat' | 'return'>('manual');
|
||||
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
|
||||
const [draftGoal, setDraftGoal] = useState('');
|
||||
const [draftMicroStep, setDraftMicroStep] = useState('');
|
||||
const [autoFocusField, setAutoFocusField] = useState<'goal' | 'microStep'>('goal');
|
||||
@@ -58,7 +66,12 @@ export const SpaceFocusHudWidget = ({
|
||||
const restReminderTimerRef = useRef<number | null>(null);
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
|
||||
const normalizedMicroStep = microStep?.trim() ? microStep.trim() : null;
|
||||
const isIntentOverlayOpen = isRefocusOpen || isMicroStepPromptOpen || sheetOpen;
|
||||
const isPausedPromptOpen = overlay === 'paused';
|
||||
const isReturnPromptOpen = overlay === 'return';
|
||||
const isRefocusOpen = overlay === 'refocus';
|
||||
const isMicroStepPromptOpen = overlay === 'next-beat';
|
||||
const isCompleteOpen = overlay === 'complete';
|
||||
const isIntentOverlayOpen = overlay !== 'none';
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -69,6 +82,32 @@ export const SpaceFocusHudWidget = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveSession) {
|
||||
setOverlay('none');
|
||||
setIntentError(null);
|
||||
setSavingIntent(false);
|
||||
setRefocusOrigin('manual');
|
||||
setCompletePreferredView('choice');
|
||||
}
|
||||
}, [hasActiveSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!returnPromptMode) {
|
||||
if (overlay === 'return') {
|
||||
setOverlay('none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlay === 'complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIntentError(null);
|
||||
setOverlay('return');
|
||||
}, [overlay, returnPromptMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleRef.current && playbackState === 'running') {
|
||||
onStatusMessage({
|
||||
@@ -89,13 +128,16 @@ export const SpaceFocusHudWidget = ({
|
||||
resumePlaybackStateRef.current = playbackState;
|
||||
}, [normalizedGoal, onStatusMessage, playbackState]);
|
||||
|
||||
const openRefocus = useCallback((field: 'goal' | 'microStep' = 'goal') => {
|
||||
const openRefocus = useCallback((
|
||||
field: 'goal' | 'microStep' = 'goal',
|
||||
origin: 'manual' | 'pause' | 'next-beat' | 'return' = 'manual',
|
||||
) => {
|
||||
setDraftGoal(goal.trim());
|
||||
setDraftMicroStep(normalizedMicroStep ?? '');
|
||||
setAutoFocusField(field);
|
||||
setIntentError(null);
|
||||
setMicroStepPromptOpen(false);
|
||||
setRefocusOpen(true);
|
||||
setRefocusOrigin(origin);
|
||||
setOverlay('refocus');
|
||||
}, [goal, normalizedMicroStep]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,31 +145,42 @@ export const SpaceFocusHudWidget = ({
|
||||
pausePlaybackStateRef.current === 'running' &&
|
||||
playbackState === 'paused' &&
|
||||
hasActiveSession &&
|
||||
!isRefocusOpen &&
|
||||
!sheetOpen
|
||||
overlay === 'none'
|
||||
) {
|
||||
openRefocus('microStep');
|
||||
setIntentError(null);
|
||||
setOverlay('paused');
|
||||
onStatusMessage({
|
||||
message: copy.space.focusHud.refocusOpenOnPause,
|
||||
});
|
||||
}
|
||||
|
||||
pausePlaybackStateRef.current = playbackState;
|
||||
}, [hasActiveSession, isRefocusOpen, onStatusMessage, openRefocus, playbackState, sheetOpen]);
|
||||
}, [hasActiveSession, onStatusMessage, overlay, playbackState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedMicroStep) {
|
||||
return;
|
||||
if (playbackState === 'running' && overlay === 'paused') {
|
||||
setOverlay('none');
|
||||
}
|
||||
}, [overlay, playbackState]);
|
||||
|
||||
setMicroStepPromptOpen(false);
|
||||
}, [normalizedMicroStep]);
|
||||
useEffect(() => {
|
||||
if (!normalizedMicroStep && overlay === 'next-beat') {
|
||||
setOverlay('none');
|
||||
}
|
||||
}, [normalizedMicroStep, overlay]);
|
||||
|
||||
const handleOpenCompleteSheet = () => {
|
||||
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
|
||||
setIntentError(null);
|
||||
setRefocusOpen(false);
|
||||
setMicroStepPromptOpen(false);
|
||||
setSheetOpen(true);
|
||||
setCompletePreferredView(preferredView);
|
||||
setOverlay('complete');
|
||||
};
|
||||
|
||||
const handleDismissReturnPrompt = () => {
|
||||
onDismissReturnPrompt?.();
|
||||
|
||||
if (overlay === 'return') {
|
||||
setOverlay('none');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefocusSubmit = async () => {
|
||||
@@ -151,10 +204,18 @@ export const SpaceFocusHudWidget = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setRefocusOpen(false);
|
||||
setOverlay('none');
|
||||
onStatusMessage({
|
||||
message: copy.space.focusHud.refocusSaved,
|
||||
});
|
||||
|
||||
if (refocusOrigin === 'return') {
|
||||
onDismissReturnPrompt?.();
|
||||
}
|
||||
|
||||
if (refocusOrigin === 'pause' && playbackState === 'paused') {
|
||||
onStartRequested?.();
|
||||
}
|
||||
} finally {
|
||||
setSavingIntent(false);
|
||||
}
|
||||
@@ -178,7 +239,7 @@ export const SpaceFocusHudWidget = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setMicroStepPromptOpen(false);
|
||||
setOverlay('none');
|
||||
onStatusMessage({
|
||||
message: copy.space.focusHud.microStepCleared,
|
||||
});
|
||||
@@ -192,37 +253,70 @@ export const SpaceFocusHudWidget = ({
|
||||
setDraftMicroStep('');
|
||||
setAutoFocusField('microStep');
|
||||
setIntentError(null);
|
||||
setMicroStepPromptOpen(false);
|
||||
setRefocusOpen(true);
|
||||
setRefocusOrigin('next-beat');
|
||||
setOverlay('refocus');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none fixed left-6 top-6 z-20 w-[min(26rem,calc(100vw-3rem))] md:left-10 md:top-9">
|
||||
<div className="pointer-events-none fixed left-6 top-6 z-20 w-[min(29rem,calc(100vw-3rem))] md:left-10 md:top-9">
|
||||
<IntentCapsule
|
||||
goal={normalizedGoal}
|
||||
microStep={microStep}
|
||||
canRefocus={Boolean(hasActiveSession)}
|
||||
canComplete={hasActiveSession && sessionPhase === 'focus'}
|
||||
showActions={!isIntentOverlayOpen}
|
||||
onOpenRefocus={() => openRefocus()}
|
||||
onOpenRefocus={() => openRefocus('goal', 'manual')}
|
||||
onMicroStepDone={() => {
|
||||
if (!normalizedMicroStep) {
|
||||
openRefocus('microStep');
|
||||
openRefocus('microStep', 'next-beat');
|
||||
return;
|
||||
}
|
||||
|
||||
setIntentError(null);
|
||||
setRefocusOpen(false);
|
||||
setMicroStepPromptOpen(true);
|
||||
setOverlay('next-beat');
|
||||
}}
|
||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||
/>
|
||||
<ReturnPrompt
|
||||
open={isReturnPromptOpen && Boolean(returnPromptMode)}
|
||||
mode={returnPromptMode === 'break' ? 'break' : 'focus'}
|
||||
isBusy={isSavingIntent}
|
||||
onContinue={() => {
|
||||
handleDismissReturnPrompt();
|
||||
}}
|
||||
onRefocus={() => {
|
||||
handleDismissReturnPrompt();
|
||||
openRefocus('microStep', 'return');
|
||||
}}
|
||||
onRest={() => {
|
||||
handleDismissReturnPrompt();
|
||||
onStatusMessage({ message: copy.space.focusHud.restReminder });
|
||||
}}
|
||||
onNextGoal={() => {
|
||||
handleDismissReturnPrompt();
|
||||
handleOpenCompleteSheet('next');
|
||||
}}
|
||||
/>
|
||||
<PauseRefocusPrompt
|
||||
open={isPausedPromptOpen}
|
||||
isBusy={isSavingIntent}
|
||||
onRefocus={() => openRefocus('microStep', 'pause')}
|
||||
onKeepCurrent={() => {
|
||||
setOverlay('none');
|
||||
onStartRequested?.();
|
||||
}}
|
||||
/>
|
||||
<RefocusSheet
|
||||
open={isRefocusOpen}
|
||||
goalDraft={draftGoal}
|
||||
microStepDraft={draftMicroStep}
|
||||
autoFocusField={autoFocusField}
|
||||
submitLabel={
|
||||
refocusOrigin === 'pause' && playbackState === 'paused'
|
||||
? copy.space.focusHud.refocusApplyAndResume
|
||||
: copy.space.focusHud.refocusApply
|
||||
}
|
||||
isSaving={isSavingIntent}
|
||||
error={intentError}
|
||||
onGoalChange={setDraftGoal}
|
||||
@@ -233,7 +327,7 @@ export const SpaceFocusHudWidget = ({
|
||||
}
|
||||
|
||||
setIntentError(null);
|
||||
setRefocusOpen(false);
|
||||
setOverlay('none');
|
||||
}}
|
||||
onSubmit={() => {
|
||||
void handleRefocusSubmit();
|
||||
@@ -241,6 +335,7 @@ export const SpaceFocusHudWidget = ({
|
||||
/>
|
||||
<NextMicroStepPrompt
|
||||
open={isMicroStepPromptOpen}
|
||||
goal={normalizedGoal}
|
||||
isSubmitting={isSavingIntent}
|
||||
error={intentError}
|
||||
onKeepGoalOnly={() => {
|
||||
@@ -249,11 +344,13 @@ export const SpaceFocusHudWidget = ({
|
||||
onDefineNext={handleDefineNextMicroStep}
|
||||
/>
|
||||
<GoalCompleteSheet
|
||||
open={sheetOpen}
|
||||
open={isCompleteOpen}
|
||||
currentGoal={goal}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
preferredView={completePreferredView}
|
||||
onClose={() => setOverlay('none')}
|
||||
onFinish={() => Promise.resolve(onGoalFinish())}
|
||||
onRest={() => {
|
||||
setSheetOpen(false);
|
||||
setOverlay('none');
|
||||
|
||||
if (restReminderTimerRef.current) {
|
||||
window.clearTimeout(restReminderTimerRef.current);
|
||||
|
||||
34
src/widgets/space-focus-hud/ui/overlayStyles.ts
Normal file
34
src/widgets/space-focus-hud/ui/overlayStyles.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const HUD_TRAY_SHELL =
|
||||
'pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#101318]/30 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125';
|
||||
|
||||
export const HUD_TRAY_LAYER =
|
||||
'pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]';
|
||||
|
||||
export const HUD_TRAY_HAIRLINE = 'pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16';
|
||||
|
||||
export const HUD_FIELD =
|
||||
'h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/30 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8';
|
||||
|
||||
export const HUD_OPTION_ROW =
|
||||
'group flex w-full items-start justify-between gap-4 rounded-[20px] border border-white/8 bg-black/10 px-4 py-3.5 text-left transition-all duration-200 hover:border-white/14 hover:bg-black/14 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/10 disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/8 disabled:text-white/30';
|
||||
|
||||
export const HUD_OPTION_ROW_PRIMARY =
|
||||
'border-white/12 bg-black/14 hover:border-white/18 hover:bg-black/18';
|
||||
|
||||
export const HUD_OPTION_CHEVRON =
|
||||
'mt-0.5 shrink-0 text-[13px] text-white/28 transition-colors duration-200 group-hover:text-white/52';
|
||||
|
||||
export const HUD_PAUSE_EYEBROW =
|
||||
'text-[11px] font-medium tracking-[0.12em] text-white/42';
|
||||
|
||||
export const HUD_PAUSE_TITLE =
|
||||
'mt-2 max-w-[24rem] text-[1.18rem] font-medium leading-[1.34] tracking-[-0.02em] text-white/95 md:text-[1.28rem]';
|
||||
|
||||
export const HUD_PAUSE_BODY =
|
||||
'mt-2 max-w-[23rem] text-[13px] leading-[1.6] text-white/58 md:text-[13.5px]';
|
||||
|
||||
export const HUD_TEXT_LINK =
|
||||
'text-[12px] font-medium tracking-[0.08em] text-white/62 underline decoration-white/16 underline-offset-4 transition-all duration-200 hover:text-white/84 hover:decoration-white/28 disabled:cursor-default disabled:text-white/26 disabled:no-underline';
|
||||
|
||||
export const HUD_TEXT_LINK_STRONG =
|
||||
'text-[12px] font-semibold tracking-[0.08em] text-white/86 underline decoration-white/22 underline-offset-4 transition-all duration-200 hover:text-white hover:decoration-white/36 disabled:cursor-default disabled:text-white/30 disabled:no-underline';
|
||||
184
src/widgets/space-workspace/model/useAwayReturnRecovery.ts
Normal file
184
src/widgets/space-workspace/model/useAwayReturnRecovery.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FocusSession } from '@/features/focus-session';
|
||||
|
||||
const AWAY_HIDDEN_THRESHOLD_MS = 20_000;
|
||||
const AWAY_SLEEP_GAP_THRESHOLD_MS = 90_000;
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
|
||||
export type ReturnPromptMode = 'focus' | 'break';
|
||||
|
||||
interface UseAwayReturnRecoveryParams {
|
||||
currentSession: FocusSession | null;
|
||||
isBootstrapping: boolean;
|
||||
syncCurrentSession: () => Promise<FocusSession | null>;
|
||||
}
|
||||
|
||||
interface UseAwayReturnRecoveryResult {
|
||||
returnPromptMode: ReturnPromptMode | null;
|
||||
dismissReturnPrompt: () => void;
|
||||
}
|
||||
|
||||
export const useAwayReturnRecovery = ({
|
||||
currentSession,
|
||||
isBootstrapping,
|
||||
syncCurrentSession,
|
||||
}: UseAwayReturnRecoveryParams): UseAwayReturnRecoveryResult => {
|
||||
const [returnPromptMode, setReturnPromptMode] = useState<ReturnPromptMode | null>(null);
|
||||
const hiddenAtRef = useRef<number | null>(null);
|
||||
const awayCandidateRef = useRef(false);
|
||||
const heartbeatAtRef = useRef(Date.now());
|
||||
const isHandlingReturnRef = useRef(false);
|
||||
|
||||
const isRunningFocusSession =
|
||||
currentSession?.state === 'running' && currentSession.phase === 'focus';
|
||||
|
||||
const clearAwayCandidate = useCallback(() => {
|
||||
hiddenAtRef.current = null;
|
||||
awayCandidateRef.current = false;
|
||||
}, []);
|
||||
|
||||
const dismissReturnPrompt = useCallback(() => {
|
||||
setReturnPromptMode(null);
|
||||
clearAwayCandidate();
|
||||
}, [clearAwayCandidate]);
|
||||
|
||||
useEffect(() => {
|
||||
heartbeatAtRef.current = Date.now();
|
||||
}, [currentSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRunningFocusSession) {
|
||||
clearAwayCandidate();
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
heartbeatAtRef.current = Date.now();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [clearAwayCandidate, isRunningFocusSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSession?.state !== 'running') {
|
||||
if (returnPromptMode === 'focus') {
|
||||
setReturnPromptMode(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSession.phase !== 'break' && returnPromptMode === 'break') {
|
||||
setReturnPromptMode(null);
|
||||
}
|
||||
}, [currentSession?.phase, currentSession?.state, returnPromptMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBootstrapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeHandleReturn = async () => {
|
||||
if (isHandlingReturnRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hiddenDuration =
|
||||
hiddenAtRef.current == null ? 0 : Date.now() - hiddenAtRef.current;
|
||||
const sleepGap = Date.now() - heartbeatAtRef.current;
|
||||
|
||||
if (!awayCandidateRef.current) {
|
||||
if (
|
||||
isRunningFocusSession &&
|
||||
document.visibilityState === 'visible' &&
|
||||
sleepGap >= AWAY_SLEEP_GAP_THRESHOLD_MS
|
||||
) {
|
||||
awayCandidateRef.current = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hiddenAtRef.current != null && hiddenDuration < AWAY_HIDDEN_THRESHOLD_MS) {
|
||||
clearAwayCandidate();
|
||||
heartbeatAtRef.current = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
isHandlingReturnRef.current = true;
|
||||
|
||||
try {
|
||||
const syncedSession = await syncCurrentSession();
|
||||
const resolvedSession = syncedSession ?? currentSession;
|
||||
|
||||
clearAwayCandidate();
|
||||
heartbeatAtRef.current = Date.now();
|
||||
|
||||
if (!resolvedSession || resolvedSession.state !== 'running') {
|
||||
setReturnPromptMode(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedSession.phase === 'focus') {
|
||||
setReturnPromptMode('focus');
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedSession.phase === 'break') {
|
||||
setReturnPromptMode('break');
|
||||
return;
|
||||
}
|
||||
|
||||
setReturnPromptMode(null);
|
||||
} finally {
|
||||
isHandlingReturnRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (isRunningFocusSession) {
|
||||
hiddenAtRef.current = Date.now();
|
||||
awayCandidateRef.current = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void maybeHandleReturn();
|
||||
};
|
||||
|
||||
const handlePageHide = () => {
|
||||
if (isRunningFocusSession) {
|
||||
hiddenAtRef.current = Date.now();
|
||||
awayCandidateRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
void maybeHandleReturn();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('pageshow', handleWindowFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
window.removeEventListener('pageshow', handleWindowFocus);
|
||||
};
|
||||
}, [clearAwayCandidate, currentSession, isBootstrapping, isRunningFocusSession, syncCurrentSession]);
|
||||
|
||||
return {
|
||||
returnPromptMode,
|
||||
dismissReturnPrompt,
|
||||
};
|
||||
};
|
||||
@@ -40,6 +40,12 @@ interface UseSpaceWorkspaceSessionControlsParams {
|
||||
goal?: string;
|
||||
microStep?: string | null;
|
||||
}) => Promise<FocusSession | null>;
|
||||
completeSession: (payload: {
|
||||
completionType: 'goal-complete' | 'timer-complete';
|
||||
completedGoal?: string;
|
||||
focusScore?: number;
|
||||
distractionCount?: number;
|
||||
}) => Promise<FocusSession | null>;
|
||||
advanceGoal: (input: {
|
||||
completedGoal: string;
|
||||
nextGoal: string;
|
||||
@@ -78,6 +84,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
updateCurrentIntent,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
setGoalInput,
|
||||
@@ -294,6 +301,47 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
unlockPlayback,
|
||||
]);
|
||||
|
||||
const handleGoalComplete = useCallback(async () => {
|
||||
const trimmedCurrentGoal = goalInput.trim();
|
||||
|
||||
if (!currentSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const completedSession = await completeSession({
|
||||
completionType: 'goal-complete',
|
||||
completedGoal: trimmedCurrentGoal || undefined,
|
||||
});
|
||||
|
||||
if (!completedSession) {
|
||||
pushStatusLine({
|
||||
message: copy.space.workspace.goalCompleteSyncFailed,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setGoalInput('');
|
||||
setLinkedFocusPlanItemId(null);
|
||||
setSelectedGoalId(null);
|
||||
setShowResumePrompt(false);
|
||||
setPendingSessionEntryPoint('space-setup');
|
||||
setPreviewPlaybackState('paused');
|
||||
setWorkspaceMode('setup');
|
||||
return true;
|
||||
}, [
|
||||
completeSession,
|
||||
currentSession,
|
||||
goalInput,
|
||||
pushStatusLine,
|
||||
setGoalInput,
|
||||
setLinkedFocusPlanItemId,
|
||||
setPendingSessionEntryPoint,
|
||||
setPreviewPlaybackState,
|
||||
setSelectedGoalId,
|
||||
setShowResumePrompt,
|
||||
setWorkspaceMode,
|
||||
]);
|
||||
|
||||
const handleIntentUpdate = useCallback(async (input: {
|
||||
goal?: string;
|
||||
microStep?: string | null;
|
||||
@@ -407,6 +455,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
handlePauseRequested,
|
||||
handleRestartRequested,
|
||||
handleIntentUpdate,
|
||||
handleGoalComplete,
|
||||
handleGoalAdvance,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 { useAwayReturnRecovery } from "../model/useAwayReturnRecovery";
|
||||
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
|
||||
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
|
||||
import {
|
||||
@@ -117,8 +118,10 @@ export const SpaceWorkspaceWidget = () => {
|
||||
restartCurrentPhase,
|
||||
updateCurrentIntent,
|
||||
updateCurrentSelection,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
syncCurrentSession,
|
||||
} = useFocusSessionEngine();
|
||||
|
||||
const isFocusMode = workspaceMode === "focus";
|
||||
@@ -191,6 +194,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
updateCurrentIntent,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
setGoalInput: selection.setGoalInput,
|
||||
@@ -199,6 +203,12 @@ export const SpaceWorkspaceWidget = () => {
|
||||
setShowResumePrompt: selection.setShowResumePrompt,
|
||||
});
|
||||
|
||||
const awayReturnRecovery = useAwayReturnRecovery({
|
||||
currentSession,
|
||||
isBootstrapping,
|
||||
syncCurrentSession,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
|
||||
router.replace("/app");
|
||||
@@ -296,6 +306,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
canStartSession={controls.canStartSession}
|
||||
canPauseSession={controls.canPauseSession}
|
||||
canRestartSession={controls.canRestartSession}
|
||||
returnPromptMode={awayReturnRecovery.returnPromptMode}
|
||||
onStartRequested={() => {
|
||||
void controls.handleStartRequested();
|
||||
}}
|
||||
@@ -305,8 +316,10 @@ export const SpaceWorkspaceWidget = () => {
|
||||
onRestartRequested={() => {
|
||||
void controls.handleRestartRequested();
|
||||
}}
|
||||
onDismissReturnPrompt={awayReturnRecovery.dismissReturnPrompt}
|
||||
onStatusMessage={pushStatusLine}
|
||||
onIntentUpdate={controls.handleIntentUpdate}
|
||||
onGoalFinish={controls.handleGoalComplete}
|
||||
onGoalUpdate={controls.handleGoalAdvance}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user