fix(space): 정리된 intent hud와 리뷰 반영
This commit is contained in:
@@ -1,14 +1,43 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useSyncExternalStore } from 'react';
|
||||||
import type { PlanTier } from './types';
|
import type { PlanTier } from './types';
|
||||||
|
|
||||||
const PLAN_TIER_STORAGE_KEY = 'viberoom:plan-tier:v1';
|
const PLAN_TIER_STORAGE_KEY = 'viberoom:plan-tier:v1';
|
||||||
|
const planTierSubscribers = new Set<() => void>();
|
||||||
|
|
||||||
const normalizePlanTier = (value: unknown): PlanTier => {
|
const normalizePlanTier = (value: unknown): PlanTier => {
|
||||||
return value === 'pro' ? 'pro' : 'normal';
|
return value === 'pro' ? 'pro' : 'normal';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const notifyPlanTierSubscribers = () => {
|
||||||
|
for (const subscriber of planTierSubscribers) {
|
||||||
|
subscriber();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeToPlanTier = (onStoreChange: () => void) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== PLAN_TIER_STORAGE_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStoreChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
planTierSubscribers.add(onStoreChange);
|
||||||
|
window.addEventListener('storage', handleStorage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
planTierSubscribers.delete(onStoreChange);
|
||||||
|
window.removeEventListener('storage', handleStorage);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const readStoredPlanTier = (): PlanTier => {
|
export const readStoredPlanTier = (): PlanTier => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return 'normal';
|
return 'normal';
|
||||||
@@ -22,41 +51,26 @@ export const readStoredPlanTier = (): PlanTier => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const usePlanTier = () => {
|
export const usePlanTier = () => {
|
||||||
const [plan, setPlanState] = useState<PlanTier>('normal');
|
const plan = useSyncExternalStore<PlanTier>(
|
||||||
const [hasHydratedPlan, setHasHydratedPlan] = useState(false);
|
subscribeToPlanTier,
|
||||||
|
readStoredPlanTier,
|
||||||
useEffect(() => {
|
() => 'normal',
|
||||||
setPlanState(readStoredPlanTier());
|
);
|
||||||
setHasHydratedPlan(true);
|
|
||||||
|
|
||||||
const handleStorage = (event: StorageEvent) => {
|
|
||||||
if (event.key !== PLAN_TIER_STORAGE_KEY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlanState(normalizePlanTier(event.newValue));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorage);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('storage', handleStorage);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setPlan = useCallback((nextPlan: PlanTier) => {
|
const setPlan = useCallback((nextPlan: PlanTier) => {
|
||||||
setPlanState(nextPlan);
|
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
window.localStorage.setItem(PLAN_TIER_STORAGE_KEY, nextPlan);
|
window.localStorage.setItem(PLAN_TIER_STORAGE_KEY, nextPlan);
|
||||||
|
} finally {
|
||||||
|
notifyPlanTierSubscribers();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan,
|
plan,
|
||||||
hasHydratedPlan,
|
|
||||||
isPro: plan === 'pro',
|
isPro: plan === 'pro',
|
||||||
setPlan,
|
setPlan,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export interface UpdateCurrentFocusSessionSelectionRequest {
|
|||||||
soundPresetId?: string | null;
|
soundPresetId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCurrentFocusSessionIntentRequest {
|
||||||
|
goal?: string;
|
||||||
|
microStep?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdvanceCurrentGoalRequest {
|
export interface AdvanceCurrentGoalRequest {
|
||||||
completedGoal: string;
|
completedGoal: string;
|
||||||
nextGoal: string;
|
nextGoal: string;
|
||||||
@@ -170,6 +175,17 @@ export const focusSessionApi = {
|
|||||||
return normalizeFocusSession(response);
|
return normalizeFocusSession(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateCurrentIntent: async (
|
||||||
|
payload: UpdateCurrentFocusSessionIntentRequest,
|
||||||
|
): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/intent', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
completeSession: async (payload: CompleteFocusSessionRequest): Promise<FocusSession> => {
|
completeSession: async (payload: CompleteFocusSessionRequest): Promise<FocusSession> => {
|
||||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/complete', {
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/complete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type FocusSessionPhase,
|
type FocusSessionPhase,
|
||||||
type FocusSessionState,
|
type FocusSessionState,
|
||||||
type StartFocusSessionRequest,
|
type StartFocusSessionRequest,
|
||||||
|
type UpdateCurrentFocusSessionIntentRequest,
|
||||||
type UpdateCurrentFocusSessionSelectionRequest,
|
type UpdateCurrentFocusSessionSelectionRequest,
|
||||||
} from '../api/focusSessionApi';
|
} from '../api/focusSessionApi';
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ interface UseFocusSessionEngineResult {
|
|||||||
pauseSession: () => Promise<FocusSession | null>;
|
pauseSession: () => Promise<FocusSession | null>;
|
||||||
resumeSession: () => Promise<FocusSession | null>;
|
resumeSession: () => Promise<FocusSession | null>;
|
||||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||||
|
updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise<FocusSession | null>;
|
||||||
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
||||||
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
||||||
advanceGoal: (payload: AdvanceCurrentGoalRequest) => Promise<AdvanceCurrentGoalResponse | null>;
|
advanceGoal: (payload: AdvanceCurrentGoalRequest) => Promise<AdvanceCurrentGoalResponse | null>;
|
||||||
@@ -120,7 +122,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
|||||||
}, [syncCurrentSession]);
|
}, [syncCurrentSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSession) {
|
if (!currentSession || currentSession.state !== 'running') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +237,18 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
|||||||
|
|
||||||
return applySession(session);
|
return applySession(session);
|
||||||
},
|
},
|
||||||
|
updateCurrentIntent: async (payload) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.updateCurrentIntent(payload),
|
||||||
|
copy.focusSession.intentUpdateFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
updateCurrentSelection: async (payload) => {
|
updateCurrentSelection: async (payload) => {
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export const core = {
|
|||||||
pauseFailed: '세션을 일시정지하지 못했어요.',
|
pauseFailed: '세션을 일시정지하지 못했어요.',
|
||||||
resumeFailed: '세션을 다시 시작하지 못했어요.',
|
resumeFailed: '세션을 다시 시작하지 못했어요.',
|
||||||
restartPhaseFailed: '현재 페이즈를 다시 시작하지 못했어요.',
|
restartPhaseFailed: '현재 페이즈를 다시 시작하지 못했어요.',
|
||||||
|
intentUpdateFailed: '현재 세션의 방향을 저장하지 못했어요.',
|
||||||
completeFailed: '세션을 완료 처리하지 못했어요.',
|
completeFailed: '세션을 완료 처리하지 못했어요.',
|
||||||
abandonFailed: '세션을 종료하지 못했어요.',
|
abandonFailed: '세션을 종료하지 못했어요.',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ export const space = {
|
|||||||
goalFallback: '집중을 시작해요.',
|
goalFallback: '집중을 시작해요.',
|
||||||
goalToast: (goal: string) => `이번 한 조각 · ${goal}`,
|
goalToast: (goal: string) => `이번 한 조각 · ${goal}`,
|
||||||
restReminder: '5분이 지났어요. 다음 한 조각으로 돌아와요.',
|
restReminder: '5분이 지났어요. 다음 한 조각으로 돌아와요.',
|
||||||
|
intentLabel: '이번 세션 목표',
|
||||||
|
microStepLabel: '지금 할 한 조각',
|
||||||
|
refocusButton: '다시 방향',
|
||||||
|
refocusTitle: '다시 방향 잡기',
|
||||||
|
refocusDescription: '딱 한 줄만 다듬고 다시 시작해요.',
|
||||||
|
refocusApply: '적용',
|
||||||
|
refocusApplying: '적용 중…',
|
||||||
|
refocusSaved: '이번 세션 방향을 다듬었어요.',
|
||||||
|
refocusOpenOnPause: '잠시 멈춘 김에 다음 한 조각을 다시 맞춰볼까요?',
|
||||||
|
microStepCompleteAriaLabel: '현재 한 조각 완료',
|
||||||
|
microStepPromptTitle: '다음 한 조각이 있나요?',
|
||||||
|
microStepPromptDescription: '리스트를 만들지 않고, 지금 다시 시작할 한 조각만 정해요.',
|
||||||
|
microStepPromptKeep: '이 목표만 유지',
|
||||||
|
microStepPromptDefine: '한 조각 정하기',
|
||||||
|
microStepCleared: '지금 할 한 조각을 비우고 목표만 유지해요.',
|
||||||
|
completeAction: '이번 목표 완료',
|
||||||
},
|
},
|
||||||
goalComplete: {
|
goalComplete: {
|
||||||
suggestions: ['리뷰 코멘트 2개 처리', '문서 1문단 다듬기', '이슈 1개 정리', '메일 2개 회신'],
|
suggestions: ['리뷰 코멘트 2개 처리', '문서 1문단 다듬기', '이슈 1개 정리', '메일 2개 회신'],
|
||||||
@@ -173,6 +189,7 @@ export const space = {
|
|||||||
pauseFailed: '세션을 일시정지하지 못했어요.',
|
pauseFailed: '세션을 일시정지하지 못했어요.',
|
||||||
restartFailed: '현재 페이즈를 다시 시작하지 못했어요.',
|
restartFailed: '현재 페이즈를 다시 시작하지 못했어요.',
|
||||||
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
|
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
|
||||||
|
intentSyncFailed: '현재 세션 방향을 서버에 반영하지 못했어요.',
|
||||||
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
||||||
nextGoalStarted: '다음 한 조각을 바로 시작했어요.',
|
nextGoalStarted: '다음 한 조각을 바로 시작했어요.',
|
||||||
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
|
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +13,6 @@ import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media'
|
|||||||
import { SOUND_PRESETS } from '@/entities/session';
|
import { SOUND_PRESETS } from '@/entities/session';
|
||||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||||
import { PlanPill } from '@/features/plan-pill';
|
import { PlanPill } from '@/features/plan-pill';
|
||||||
import { useFocusStats } from '@/features/stats';
|
|
||||||
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
|
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
|
||||||
import { copy } from '@/shared/i18n';
|
import { copy } from '@/shared/i18n';
|
||||||
import { cn } from '@/shared/lib/cn';
|
import { cn } from '@/shared/lib/cn';
|
||||||
@@ -85,7 +83,7 @@ const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px
|
|||||||
export const FocusDashboardWidget = () => {
|
export const FocusDashboardWidget = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { plan: planTier, isPro, setPlan } = usePlanTier();
|
const { plan: planTier, isPro, setPlan } = usePlanTier();
|
||||||
const { plan, isLoading, isSaving, error, source, createItem, updateItem, deleteItem } = useFocusPlan();
|
const { plan, isLoading, isSaving, source, createItem, updateItem, deleteItem } = useFocusPlan();
|
||||||
const { sceneAssetMap } = useMediaCatalog();
|
const { sceneAssetMap } = useMediaCatalog();
|
||||||
|
|
||||||
const [step, setStep] = useState<DashboardStep>('goal');
|
const [step, setStep] = useState<DashboardStep>('goal');
|
||||||
@@ -124,11 +122,16 @@ export const FocusDashboardWidget = () => {
|
|||||||
}, [maxItems, plan.currentItem, plan.nextItems]);
|
}, [maxItems, plan.currentItem, plan.nextItems]);
|
||||||
|
|
||||||
const currentItem = planItems[0] ?? null;
|
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 hasPendingEdit = editingState !== null;
|
||||||
const canAddMore = planItems.length < maxItems;
|
const canAddMore = planItems.length < maxItems;
|
||||||
const canManagePlan = source === 'api' && !isLoading;
|
const canManagePlan = source === 'api' && !isLoading;
|
||||||
const trimmedEntryGoal = entryDraft.trim();
|
const trimmedEntryGoal = resolvedEntryDraft.trim();
|
||||||
const isGoalReady = trimmedEntryGoal.length > 0;
|
const isGoalReady = trimmedEntryGoal.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -140,15 +143,6 @@ export const FocusDashboardWidget = () => {
|
|||||||
return () => window.cancelAnimationFrame(rafId);
|
return () => window.cancelAnimationFrame(rafId);
|
||||||
}, [editingState]);
|
}, [editingState]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentItem) return;
|
|
||||||
if (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)) {
|
|
||||||
setEntryDraft(currentItem.title);
|
|
||||||
setSelectedPlanItemId(currentItem.id);
|
|
||||||
setEntrySource('plan');
|
|
||||||
}
|
|
||||||
}, [currentItem, entryDraft, entrySource, selectedPlanItemId]);
|
|
||||||
|
|
||||||
const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource);
|
const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource);
|
||||||
|
|
||||||
const handleSelectPlanItem = (item: FocusPlanItem) => {
|
const handleSelectPlanItem = (item: FocusPlanItem) => {
|
||||||
@@ -162,7 +156,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
const handleSelectSuggestion = (goal: string) => {
|
const handleSelectSuggestion = (goal: string) => {
|
||||||
setEntryDraft(goal);
|
setEntryDraft(goal);
|
||||||
setSelectedPlanItemId(null);
|
setSelectedPlanItemId(null);
|
||||||
setEntrySource('starter');
|
setEntrySource('custom');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEntryDraftChange = (value: string) => {
|
const handleEntryDraftChange = (value: string) => {
|
||||||
@@ -224,7 +218,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
|
const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
|
||||||
if (!nextPlan) return;
|
if (!nextPlan) return;
|
||||||
setEditingState(null);
|
setEditingState(null);
|
||||||
if (selectedPlanItemId === editingState.itemId) {
|
if (resolvedSelectedPlanItemId === editingState.itemId) {
|
||||||
setEntryDraft(trimmedTitle);
|
setEntryDraft(trimmedTitle);
|
||||||
setEntrySource('plan');
|
setEntrySource('plan');
|
||||||
}
|
}
|
||||||
@@ -236,7 +230,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
|
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
|
||||||
setEditingState(null);
|
setEditingState(null);
|
||||||
}
|
}
|
||||||
if (selectedPlanItemId === itemId) {
|
if (resolvedSelectedPlanItemId === itemId) {
|
||||||
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||||
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||||
if (nextCurrentItem) {
|
if (nextCurrentItem) {
|
||||||
@@ -270,7 +264,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
sceneId: selectedSceneId,
|
sceneId: selectedSceneId,
|
||||||
soundPresetId: selectedSoundId,
|
soundPresetId: selectedSoundId,
|
||||||
timerPresetId: selectedTimerId,
|
timerPresetId: selectedTimerId,
|
||||||
focusPlanItemId: selectedPlanItemId || undefined,
|
focusPlanItemId: resolvedSelectedPlanItemId || undefined,
|
||||||
entryPoint: 'space-setup'
|
entryPoint: 'space-setup'
|
||||||
});
|
});
|
||||||
router.push('/space');
|
router.push('/space');
|
||||||
@@ -328,7 +322,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
<div className="w-full max-w-xl mx-auto space-y-8">
|
<div className="w-full max-w-xl mx-auto space-y-8">
|
||||||
<input
|
<input
|
||||||
ref={entryInputRef}
|
ref={entryInputRef}
|
||||||
value={entryDraft}
|
value={resolvedEntryDraft}
|
||||||
onChange={(event) => handleEntryDraftChange(event.target.value)}
|
onChange={(event) => handleEntryDraftChange(event.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
|
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
|
||||||
placeholder={focusEntryCopy.inputPlaceholder}
|
placeholder={focusEntryCopy.inputPlaceholder}
|
||||||
@@ -359,7 +353,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
<div className="pt-8 flex flex-col items-center gap-4 opacity-70 hover:opacity-100 transition-opacity">
|
<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">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{ENTRY_SUGGESTIONS.map((suggestion) => {
|
{ENTRY_SUGGESTIONS.map((suggestion) => {
|
||||||
const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
|
const isActive = resolvedSelectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={suggestion.id}
|
key={suggestion.id}
|
||||||
@@ -400,7 +394,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
<div className={panelGlassClass}>
|
<div className={panelGlassClass}>
|
||||||
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
|
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-xs uppercase tracking-widest text-white/40 mb-2">Today's Focus</p>
|
<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>
|
<p className="text-xl md:text-2xl font-light text-white truncate pr-4">{trimmedEntryGoal}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
'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%)] group">
|
|
||||||
<div className="flex flex-col items-start gap-4 p-8 md:p-12">
|
|
||||||
{/* Main Goal */}
|
|
||||||
<div className="pointer-events-auto 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 focus-visible:opacity-100"
|
|
||||||
>
|
|
||||||
목표 달성
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -78,48 +78,58 @@ export const GoalCompleteSheet = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 transition-all duration-[260ms] ease-out motion-reduce:duration-0',
|
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-0',
|
||||||
open ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0',
|
open
|
||||||
|
? 'max-h-[28rem] translate-y-0 opacity-100'
|
||||||
|
: 'pointer-events-none max-h-0 -translate-y-2 opacity-0',
|
||||||
)}
|
)}
|
||||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 4.9rem)' }}
|
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
>
|
>
|
||||||
<section className="pointer-events-auto w-[min(460px,94vw)] rounded-2xl border border-white/12 bg-black/26 px-3.5 py-3 text-white shadow-[0_14px_30px_rgba(2,6,23,0.28)] backdrop-blur-md">
|
<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">
|
||||||
<header className="flex items-start justify-between gap-2">
|
<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" />
|
||||||
|
|
||||||
|
<header className="relative flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-white/92">{copy.space.goalComplete.title}</h3>
|
<p className="text-[11px] font-medium tracking-[0.08em] text-white/38">목표 완료</p>
|
||||||
<p className="mt-0.5 text-[11px] text-white/58">{copy.space.goalComplete.description}</p>
|
<h3 className="mt-1 text-[1rem] font-medium tracking-tight text-white/94">{copy.space.goalComplete.title}</h3>
|
||||||
|
<p className="mt-1 text-[12px] text-white/56">{copy.space.goalComplete.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-[11px] text-white/72 transition-colors hover:bg-white/[0.12]"
|
disabled={isSubmitting}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/14 text-[11px] text-white/72 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
|
||||||
aria-label={copy.space.goalComplete.closeAriaLabel}
|
aria-label={copy.space.goalComplete.closeAriaLabel}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form className="mt-2.5 space-y-2.5" onSubmit={handleSubmit}>
|
<form className="relative mt-3 space-y-3" onSubmit={handleSubmit}>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
placeholder={placeholder}
|
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"
|
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">
|
<footer className="mt-3 flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRest}
|
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]"
|
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}
|
{copy.space.goalComplete.restButton}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canConfirm || isSubmitting}
|
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"
|
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}
|
{isSubmitting ? copy.space.goalComplete.confirmPending : copy.space.goalComplete.confirmButton}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
105
src/widgets/space-focus-hud/ui/IntentCapsule.tsx
Normal file
105
src/widgets/space-focus-hud/ui/IntentCapsule.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
|
interface IntentCapsuleProps {
|
||||||
|
goal: string;
|
||||||
|
microStep?: string | null;
|
||||||
|
canRefocus: boolean;
|
||||||
|
canComplete?: boolean;
|
||||||
|
showActions?: boolean;
|
||||||
|
onOpenRefocus: () => void;
|
||||||
|
onMicroStepDone: () => void;
|
||||||
|
onGoalCompleteRequest?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntentCapsule = ({
|
||||||
|
goal,
|
||||||
|
microStep,
|
||||||
|
canRefocus,
|
||||||
|
canComplete = false,
|
||||||
|
showActions = true,
|
||||||
|
onOpenRefocus,
|
||||||
|
onMicroStepDone,
|
||||||
|
onGoalCompleteRequest,
|
||||||
|
}: IntentCapsuleProps) => {
|
||||||
|
const normalizedMicroStep = microStep?.trim() ? microStep.trim() : null;
|
||||||
|
const microGlyphClass =
|
||||||
|
'inline-flex h-5 w-5 items-center justify-center rounded-full border border-white/20 text-white/62 transition-all duration-200 hover:border-white/36 hover:text-white focus-visible:border-white/36 focus-visible:text-white disabled:cursor-default disabled:opacity-30';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none w-full">
|
||||||
|
<section className="pointer-events-auto relative w-full overflow-hidden rounded-[24px] border border-white/10 bg-[#0f1115]/24 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-[24px] 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={canRefocus ? onOpenRefocus : undefined}
|
||||||
|
disabled={!canRefocus || !showActions}
|
||||||
|
aria-label={copy.space.focusHud.refocusButton}
|
||||||
|
className="block min-w-0 w-full max-w-full text-left transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/14 disabled:cursor-default disabled:hover:opacity-100"
|
||||||
|
>
|
||||||
|
<p className="truncate text-[18px] font-medium tracking-tight text-white/96 md:text-[20px]">
|
||||||
|
{goal}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{normalizedMicroStep ? (
|
||||||
|
<div className="mt-3 flex items-center gap-3 border-t border-white/10 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={showActions ? onMicroStepDone : undefined}
|
||||||
|
disabled={!showActions}
|
||||||
|
className={cn(microGlyphClass, 'shrink-0 bg-black/12')}
|
||||||
|
aria-label={copy.space.focusHud.microStepCompleteAriaLabel}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
>
|
||||||
|
<path d="M4 8.4 6.7 11 12 5.7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<p className="min-w-0 flex-1 truncate text-left text-[14px] text-white/80">
|
||||||
|
{normalizedMicroStep}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 border-t border-white/10 pt-3 text-[14px] text-white/56">
|
||||||
|
{copy.space.focusHud.refocusDescription}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showActions && canComplete ? (
|
||||||
|
<div className="mt-4 flex items-center justify-end border-t border-white/10 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onGoalCompleteRequest}
|
||||||
|
className="text-[12px] font-medium tracking-[0.08em] text-white/62 underline decoration-white/10 underline-offset-4 transition-colors hover:text-white/84 hover:decoration-white/22"
|
||||||
|
>
|
||||||
|
{copy.space.focusHud.completeAction}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
75
src/widgets/space-focus-hud/ui/NextMicroStepPrompt.tsx
Normal file
75
src/widgets/space-focus-hud/ui/NextMicroStepPrompt.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
|
interface NextMicroStepPromptProps {
|
||||||
|
open: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onKeepGoalOnly: () => void;
|
||||||
|
onDefineNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NextMicroStepPrompt = ({
|
||||||
|
open,
|
||||||
|
isSubmitting,
|
||||||
|
error,
|
||||||
|
onKeepGoalOnly,
|
||||||
|
onDefineNext,
|
||||||
|
}: NextMicroStepPromptProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-0',
|
||||||
|
open
|
||||||
|
? 'max-h-[18rem] translate-y-0 opacity-100'
|
||||||
|
: 'pointer-events-none max-h-0 -translate-y-2 opacity-0',
|
||||||
|
)}
|
||||||
|
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" />
|
||||||
|
|
||||||
|
<div className="relative w-full">
|
||||||
|
<p className="text-[11px] font-medium tracking-[0.08em] text-white/42">다음 한 조각</p>
|
||||||
|
<h3 className="mt-1 text-[1rem] font-medium tracking-tight text-white/94">
|
||||||
|
{copy.space.focusHud.microStepPromptTitle}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-[13px] text-white/56">
|
||||||
|
{copy.space.focusHud.microStepPromptDescription}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{copy.space.focusHud.microStepPromptKeep}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{copy.space.focusHud.microStepPromptDefine}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
165
src/widgets/space-focus-hud/ui/RefocusSheet.tsx
Normal file
165
src/widgets/space-focus-hud/ui/RefocusSheet.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
|
interface RefocusSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
goalDraft: string;
|
||||||
|
microStepDraft: string;
|
||||||
|
autoFocusField: 'goal' | 'microStep';
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onGoalChange: (value: string) => void;
|
||||||
|
onMicroStepChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RefocusSheet = ({
|
||||||
|
open,
|
||||||
|
goalDraft,
|
||||||
|
microStepDraft,
|
||||||
|
autoFocusField,
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
onGoalChange,
|
||||||
|
onMicroStepChange,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: RefocusSheetProps) => {
|
||||||
|
const goalRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const microStepRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
|
if (autoFocusField === 'microStep') {
|
||||||
|
microStepRef.current?.focus();
|
||||||
|
microStepRef.current?.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
goalRef.current?.focus();
|
||||||
|
goalRef.current?.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [autoFocusField, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && !isSaving) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [isSaving, onClose, open]);
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (isSaving || goalDraft.trim().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-0',
|
||||||
|
open
|
||||||
|
? 'max-h-[32rem] translate-y-0 opacity-100'
|
||||||
|
: 'pointer-events-none max-h-0 -translate-y-2 opacity-0',
|
||||||
|
)}
|
||||||
|
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" />
|
||||||
|
|
||||||
|
<header className="relative px-5 pt-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-medium tracking-[0.08em] text-white/42">다시 방향 잡기</p>
|
||||||
|
<h3 className="mt-1 text-[0.98rem] font-medium tracking-tight text-white/95">
|
||||||
|
{copy.space.focusHud.refocusTitle}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-[13px] text-white/56">{copy.space.focusHud.refocusDescription}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="relative mt-5 space-y-4 px-5 pb-5" onSubmit={handleSubmit}>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-[11px] font-medium uppercase tracking-[0.22em] text-white/44">
|
||||||
|
{copy.space.focusHud.intentLabel}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={goalRef}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-[11px] font-medium uppercase tracking-[0.22em] text-white/44">
|
||||||
|
{copy.space.focusHud.microStepLabel}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={microStepRef}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-[18px] border border-rose-300/12 bg-rose-300/8 px-3 py-2 text-[12px] text-rose-100/86">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{isSaving ? copy.space.focusHud.refocusApplying : copy.space.focusHud.refocusApply}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { copy } from '@/shared/i18n';
|
import { copy } from '@/shared/i18n';
|
||||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||||
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||||
import { FloatingGoalWidget } from './FloatingGoalWidget';
|
|
||||||
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
||||||
|
import { IntentCapsule } from './IntentCapsule';
|
||||||
|
import { NextMicroStepPrompt } from './NextMicroStepPrompt';
|
||||||
|
import { RefocusSheet } from './RefocusSheet';
|
||||||
|
|
||||||
interface SpaceFocusHudWidgetProps {
|
interface SpaceFocusHudWidgetProps {
|
||||||
goal: string;
|
goal: string;
|
||||||
microStep?: string | null;
|
microStep?: string | null;
|
||||||
timerLabel: string;
|
|
||||||
timeDisplay?: string;
|
timeDisplay?: string;
|
||||||
visible: boolean;
|
|
||||||
hasActiveSession?: boolean;
|
hasActiveSession?: boolean;
|
||||||
playbackState?: 'running' | 'paused';
|
playbackState?: 'running' | 'paused';
|
||||||
sessionPhase?: 'focus' | 'break' | null;
|
sessionPhase?: 'focus' | 'break' | null;
|
||||||
@@ -21,7 +21,7 @@ interface SpaceFocusHudWidgetProps {
|
|||||||
onStartRequested?: () => void;
|
onStartRequested?: () => void;
|
||||||
onPauseRequested?: () => void;
|
onPauseRequested?: () => void;
|
||||||
onRestartRequested?: () => void;
|
onRestartRequested?: () => void;
|
||||||
onExitRequested?: () => void;
|
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
|
||||||
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
|
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
|
||||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||||
}
|
}
|
||||||
@@ -29,9 +29,7 @@ interface SpaceFocusHudWidgetProps {
|
|||||||
export const SpaceFocusHudWidget = ({
|
export const SpaceFocusHudWidget = ({
|
||||||
goal,
|
goal,
|
||||||
microStep,
|
microStep,
|
||||||
timerLabel,
|
|
||||||
timeDisplay,
|
timeDisplay,
|
||||||
visible,
|
|
||||||
hasActiveSession = false,
|
hasActiveSession = false,
|
||||||
playbackState = 'paused',
|
playbackState = 'paused',
|
||||||
sessionPhase = 'focus',
|
sessionPhase = 'focus',
|
||||||
@@ -42,15 +40,25 @@ export const SpaceFocusHudWidget = ({
|
|||||||
onStartRequested,
|
onStartRequested,
|
||||||
onPauseRequested,
|
onPauseRequested,
|
||||||
onRestartRequested,
|
onRestartRequested,
|
||||||
onExitRequested,
|
onIntentUpdate,
|
||||||
onGoalUpdate,
|
onGoalUpdate,
|
||||||
onStatusMessage,
|
onStatusMessage,
|
||||||
}: SpaceFocusHudWidgetProps) => {
|
}: SpaceFocusHudWidgetProps) => {
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [isRefocusOpen, setRefocusOpen] = useState(false);
|
||||||
|
const [isMicroStepPromptOpen, setMicroStepPromptOpen] = useState(false);
|
||||||
|
const [draftGoal, setDraftGoal] = useState('');
|
||||||
|
const [draftMicroStep, setDraftMicroStep] = useState('');
|
||||||
|
const [autoFocusField, setAutoFocusField] = useState<'goal' | 'microStep'>('goal');
|
||||||
|
const [isSavingIntent, setSavingIntent] = useState(false);
|
||||||
|
const [intentError, setIntentError] = useState<string | null>(null);
|
||||||
const visibleRef = useRef(false);
|
const visibleRef = useRef(false);
|
||||||
const playbackStateRef = useRef<'running' | 'paused'>(playbackState);
|
const resumePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
|
||||||
|
const pausePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
|
||||||
const restReminderTimerRef = useRef<number | null>(null);
|
const restReminderTimerRef = useRef<number | null>(null);
|
||||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
|
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
|
||||||
|
const normalizedMicroStep = microStep?.trim() ? microStep.trim() : null;
|
||||||
|
const isIntentOverlayOpen = isRefocusOpen || isMicroStepPromptOpen || sheetOpen;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -62,57 +70,183 @@ export const SpaceFocusHudWidget = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && !visibleRef.current && playbackState === 'running') {
|
if (!visibleRef.current && playbackState === 'running') {
|
||||||
onStatusMessage({
|
onStatusMessage({
|
||||||
message: copy.space.focusHud.goalToast(normalizedGoal),
|
message: copy.space.focusHud.goalToast(normalizedGoal),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
visibleRef.current = visible;
|
visibleRef.current = true;
|
||||||
}, [normalizedGoal, onStatusMessage, playbackState, visible]);
|
}, [normalizedGoal, onStatusMessage, playbackState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playbackStateRef.current === 'paused' && playbackState === 'running' && visible) {
|
if (resumePlaybackStateRef.current === 'paused' && playbackState === 'running') {
|
||||||
onStatusMessage({
|
onStatusMessage({
|
||||||
message: copy.space.focusHud.goalToast(normalizedGoal),
|
message: copy.space.focusHud.goalToast(normalizedGoal),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackStateRef.current = playbackState;
|
resumePlaybackStateRef.current = playbackState;
|
||||||
}, [normalizedGoal, onStatusMessage, playbackState, visible]);
|
}, [normalizedGoal, onStatusMessage, playbackState]);
|
||||||
|
|
||||||
if (!visible) {
|
const openRefocus = useCallback((field: 'goal' | 'microStep' = 'goal') => {
|
||||||
return null;
|
setDraftGoal(goal.trim());
|
||||||
|
setDraftMicroStep(normalizedMicroStep ?? '');
|
||||||
|
setAutoFocusField(field);
|
||||||
|
setIntentError(null);
|
||||||
|
setMicroStepPromptOpen(false);
|
||||||
|
setRefocusOpen(true);
|
||||||
|
}, [goal, normalizedMicroStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
pausePlaybackStateRef.current === 'running' &&
|
||||||
|
playbackState === 'paused' &&
|
||||||
|
hasActiveSession &&
|
||||||
|
!isRefocusOpen &&
|
||||||
|
!sheetOpen
|
||||||
|
) {
|
||||||
|
openRefocus('microStep');
|
||||||
|
onStatusMessage({
|
||||||
|
message: copy.space.focusHud.refocusOpenOnPause,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pausePlaybackStateRef.current = playbackState;
|
||||||
|
}, [hasActiveSession, isRefocusOpen, onStatusMessage, openRefocus, playbackState, sheetOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (normalizedMicroStep) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMicroStepPromptOpen(false);
|
||||||
|
}, [normalizedMicroStep]);
|
||||||
|
|
||||||
const handleOpenCompleteSheet = () => {
|
const handleOpenCompleteSheet = () => {
|
||||||
|
setIntentError(null);
|
||||||
|
setRefocusOpen(false);
|
||||||
|
setMicroStepPromptOpen(false);
|
||||||
setSheetOpen(true);
|
setSheetOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefocusSubmit = async () => {
|
||||||
|
const trimmedGoal = draftGoal.trim();
|
||||||
|
|
||||||
|
if (!trimmedGoal || isSavingIntent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingIntent(true);
|
||||||
|
setIntentError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const didUpdate = await onIntentUpdate({
|
||||||
|
goal: trimmedGoal,
|
||||||
|
microStep: draftMicroStep.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!didUpdate) {
|
||||||
|
setIntentError(copy.space.workspace.intentSyncFailed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRefocusOpen(false);
|
||||||
|
onStatusMessage({
|
||||||
|
message: copy.space.focusHud.refocusSaved,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingIntent(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeepGoalOnly = async () => {
|
||||||
|
if (isSavingIntent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingIntent(true);
|
||||||
|
setIntentError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const didUpdate = await onIntentUpdate({
|
||||||
|
microStep: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!didUpdate) {
|
||||||
|
setIntentError(copy.space.workspace.intentSyncFailed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMicroStepPromptOpen(false);
|
||||||
|
onStatusMessage({
|
||||||
|
message: copy.space.focusHud.microStepCleared,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingIntent(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDefineNextMicroStep = () => {
|
||||||
|
setDraftGoal(goal.trim());
|
||||||
|
setDraftMicroStep('');
|
||||||
|
setAutoFocusField('microStep');
|
||||||
|
setIntentError(null);
|
||||||
|
setMicroStepPromptOpen(false);
|
||||||
|
setRefocusOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FloatingGoalWidget
|
<div className="pointer-events-none fixed left-6 top-6 z-20 w-[min(26rem,calc(100vw-3rem))] md:left-10 md:top-9">
|
||||||
goal={goal}
|
<IntentCapsule
|
||||||
|
goal={normalizedGoal}
|
||||||
microStep={microStep}
|
microStep={microStep}
|
||||||
|
canRefocus={Boolean(hasActiveSession)}
|
||||||
|
canComplete={hasActiveSession && sessionPhase === 'focus'}
|
||||||
|
showActions={!isIntentOverlayOpen}
|
||||||
|
onOpenRefocus={() => openRefocus()}
|
||||||
|
onMicroStepDone={() => {
|
||||||
|
if (!normalizedMicroStep) {
|
||||||
|
openRefocus('microStep');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIntentError(null);
|
||||||
|
setRefocusOpen(false);
|
||||||
|
setMicroStepPromptOpen(true);
|
||||||
|
}}
|
||||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||||
hasActiveSession={hasActiveSession}
|
|
||||||
sessionPhase={sessionPhase}
|
|
||||||
/>
|
/>
|
||||||
<SpaceTimerHudWidget
|
<RefocusSheet
|
||||||
timerLabel={timerLabel}
|
open={isRefocusOpen}
|
||||||
timeDisplay={timeDisplay}
|
goalDraft={draftGoal}
|
||||||
isImmersionMode
|
microStepDraft={draftMicroStep}
|
||||||
hasActiveSession={hasActiveSession}
|
autoFocusField={autoFocusField}
|
||||||
sessionPhase={sessionPhase}
|
isSaving={isSavingIntent}
|
||||||
playbackState={playbackState}
|
error={intentError}
|
||||||
isControlsDisabled={isSessionActionPending}
|
onGoalChange={setDraftGoal}
|
||||||
canStart={canStartSession}
|
onMicroStepChange={setDraftMicroStep}
|
||||||
canPause={canPauseSession}
|
onClose={() => {
|
||||||
canReset={canRestartSession}
|
if (isSavingIntent) {
|
||||||
className="pr-[4.2rem]"
|
return;
|
||||||
onStartClick={onStartRequested}
|
}
|
||||||
onPauseClick={onPauseRequested}
|
|
||||||
onResetClick={onRestartRequested}
|
setIntentError(null);
|
||||||
|
setRefocusOpen(false);
|
||||||
|
}}
|
||||||
|
onSubmit={() => {
|
||||||
|
void handleRefocusSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NextMicroStepPrompt
|
||||||
|
open={isMicroStepPromptOpen}
|
||||||
|
isSubmitting={isSavingIntent}
|
||||||
|
error={intentError}
|
||||||
|
onKeepGoalOnly={() => {
|
||||||
|
void handleKeepGoalOnly();
|
||||||
|
}}
|
||||||
|
onDefineNext={handleDefineNextMicroStep}
|
||||||
/>
|
/>
|
||||||
<GoalCompleteSheet
|
<GoalCompleteSheet
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
@@ -134,6 +268,22 @@ export const SpaceFocusHudWidget = ({
|
|||||||
return Promise.resolve(onGoalUpdate(nextGoal));
|
return Promise.resolve(onGoalUpdate(nextGoal));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<SpaceTimerHudWidget
|
||||||
|
timeDisplay={timeDisplay}
|
||||||
|
isImmersionMode
|
||||||
|
hasActiveSession={hasActiveSession}
|
||||||
|
sessionPhase={sessionPhase}
|
||||||
|
playbackState={playbackState}
|
||||||
|
isControlsDisabled={isSessionActionPending}
|
||||||
|
canStart={canStartSession}
|
||||||
|
canPause={canPauseSession}
|
||||||
|
canReset={canRestartSession}
|
||||||
|
className="pr-[4.2rem]"
|
||||||
|
onStartClick={onStartRequested}
|
||||||
|
onPauseClick={onPauseRequested}
|
||||||
|
onResetClick={onRestartRequested}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '@/features/restart-30s';
|
} from '@/features/restart-30s';
|
||||||
|
|
||||||
interface SpaceTimerHudWidgetProps {
|
interface SpaceTimerHudWidgetProps {
|
||||||
timerLabel: string;
|
|
||||||
timeDisplay?: string;
|
timeDisplay?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
hasActiveSession?: boolean;
|
hasActiveSession?: boolean;
|
||||||
@@ -28,7 +27,6 @@ interface SpaceTimerHudWidgetProps {
|
|||||||
const HUD_ACTIONS = copy.space.timerHud.actions;
|
const HUD_ACTIONS = copy.space.timerHud.actions;
|
||||||
|
|
||||||
export const SpaceTimerHudWidget = ({
|
export const SpaceTimerHudWidget = ({
|
||||||
timerLabel,
|
|
||||||
timeDisplay = '25:00',
|
timeDisplay = '25:00',
|
||||||
className,
|
className,
|
||||||
hasActiveSession = false,
|
hasActiveSession = false,
|
||||||
|
|||||||
@@ -155,13 +155,13 @@ export const useSpaceToolsDockHandlers = ({
|
|||||||
: toolsDock.featureLabels.weeklyReview;
|
: toolsDock.featureLabels.weeklyReview;
|
||||||
|
|
||||||
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
|
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
|
||||||
}, [onStatusMessage, toolsDock.featureLabels]);
|
}, [onStatusMessage, toolsDock]);
|
||||||
|
|
||||||
const handleStartPro = useCallback(() => {
|
const handleStartPro = useCallback(() => {
|
||||||
setPlan('pro');
|
setPlan('pro');
|
||||||
onStatusMessage({ message: toolsDock.purchaseMock });
|
onStatusMessage({ message: toolsDock.purchaseMock });
|
||||||
openUtilityPanel('control-center');
|
openUtilityPanel('control-center');
|
||||||
}, [onStatusMessage, openUtilityPanel, toolsDock.purchaseMock]);
|
}, [onStatusMessage, openUtilityPanel, setPlan, toolsDock.purchaseMock]);
|
||||||
|
|
||||||
const handleVolumeChange = useCallback((nextVolume: number) => {
|
const handleVolumeChange = useCallback((nextVolume: number) => {
|
||||||
const clamped = Math.min(100, Math.max(0, nextVolume));
|
const clamped = Math.min(100, Math.max(0, nextVolume));
|
||||||
|
|||||||
@@ -3,12 +3,8 @@
|
|||||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
import type { SoundPreset } from '@/entities/session';
|
import type { SoundPreset } from '@/entities/session';
|
||||||
import { copy } from '@/shared/i18n';
|
import { copy } from '@/shared/i18n';
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import type { SpaceAnchorPopoverId } from '../model/types';
|
import type { SpaceAnchorPopoverId } from '../model/types';
|
||||||
import { ANCHOR_ICON, formatThoughtCount } from './constants';
|
|
||||||
import { FocusRightRail } from './FocusRightRail';
|
import { FocusRightRail } from './FocusRightRail';
|
||||||
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
|
|
||||||
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
|
|
||||||
|
|
||||||
interface FocusModeAnchorsProps {
|
interface FocusModeAnchorsProps {
|
||||||
isFocusMode: boolean;
|
isFocusMode: boolean;
|
||||||
@@ -35,15 +31,6 @@ interface FocusModeAnchorsProps {
|
|||||||
onSelectPreset: (presetId: string) => void;
|
onSelectPreset: (presetId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorContainerClassName =
|
|
||||||
'fixed z-30 transition-opacity bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]';
|
|
||||||
|
|
||||||
const anchorHaloClassName =
|
|
||||||
'pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]';
|
|
||||||
|
|
||||||
const anchorButtonClassName =
|
|
||||||
'inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100';
|
|
||||||
|
|
||||||
export const FocusModeAnchors = ({
|
export const FocusModeAnchors = ({
|
||||||
isFocusMode,
|
isFocusMode,
|
||||||
isIdle,
|
isIdle,
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ interface UseSpaceWorkspaceSessionControlsParams {
|
|||||||
pauseSession: () => Promise<FocusSession | null>;
|
pauseSession: () => Promise<FocusSession | null>;
|
||||||
resumeSession: () => Promise<FocusSession | null>;
|
resumeSession: () => Promise<FocusSession | null>;
|
||||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||||
|
updateCurrentIntent: (payload: {
|
||||||
|
goal?: string;
|
||||||
|
microStep?: string | null;
|
||||||
|
}) => Promise<FocusSession | null>;
|
||||||
advanceGoal: (input: {
|
advanceGoal: (input: {
|
||||||
completedGoal: string;
|
completedGoal: string;
|
||||||
nextGoal: string;
|
nextGoal: string;
|
||||||
@@ -73,6 +77,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
|||||||
pauseSession,
|
pauseSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
restartCurrentPhase,
|
restartCurrentPhase,
|
||||||
|
updateCurrentIntent,
|
||||||
advanceGoal,
|
advanceGoal,
|
||||||
abandonSession,
|
abandonSession,
|
||||||
setGoalInput,
|
setGoalInput,
|
||||||
@@ -289,6 +294,50 @@ export const useSpaceWorkspaceSessionControls = ({
|
|||||||
unlockPlayback,
|
unlockPlayback,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleIntentUpdate = useCallback(async (input: {
|
||||||
|
goal?: string;
|
||||||
|
microStep?: string | null;
|
||||||
|
}) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextGoal = input.goal?.trim();
|
||||||
|
const nextMicroStep = input.microStep === undefined
|
||||||
|
? undefined
|
||||||
|
: input.microStep?.trim() || null;
|
||||||
|
|
||||||
|
if (input.goal !== undefined && !nextGoal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSession = await updateCurrentIntent({
|
||||||
|
goal: nextGoal,
|
||||||
|
microStep: nextMicroStep,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedSession) {
|
||||||
|
pushStatusLine({
|
||||||
|
message: copy.space.workspace.intentSyncFailed,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGoalInput(updatedSession.goal);
|
||||||
|
setLinkedFocusPlanItemId(updatedSession.focusPlanItemId ?? null);
|
||||||
|
setSelectedGoalId(null);
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
return true;
|
||||||
|
}, [
|
||||||
|
currentSession,
|
||||||
|
pushStatusLine,
|
||||||
|
setGoalInput,
|
||||||
|
setLinkedFocusPlanItemId,
|
||||||
|
setSelectedGoalId,
|
||||||
|
setShowResumePrompt,
|
||||||
|
updateCurrentIntent,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousBodyOverflow = document.body.style.overflow;
|
const previousBodyOverflow = document.body.style.overflow;
|
||||||
const previousHtmlOverflow = document.documentElement.style.overflow;
|
const previousHtmlOverflow = document.documentElement.style.overflow;
|
||||||
@@ -357,6 +406,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
|||||||
handleExitRequested,
|
handleExitRequested,
|
||||||
handlePauseRequested,
|
handlePauseRequested,
|
||||||
handleRestartRequested,
|
handleRestartRequested,
|
||||||
|
handleIntentUpdate,
|
||||||
handleGoalAdvance,
|
handleGoalAdvance,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,12 +110,12 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
isBootstrapping,
|
isBootstrapping,
|
||||||
isMutating: isSessionMutating,
|
isMutating: isSessionMutating,
|
||||||
timeDisplay,
|
timeDisplay,
|
||||||
playbackState,
|
|
||||||
phase,
|
phase,
|
||||||
startSession,
|
startSession,
|
||||||
pauseSession,
|
pauseSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
restartCurrentPhase,
|
restartCurrentPhase,
|
||||||
|
updateCurrentIntent,
|
||||||
updateCurrentSelection,
|
updateCurrentSelection,
|
||||||
advanceGoal,
|
advanceGoal,
|
||||||
abandonSession,
|
abandonSession,
|
||||||
@@ -190,6 +190,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
pauseSession,
|
pauseSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
restartCurrentPhase,
|
restartCurrentPhase,
|
||||||
|
updateCurrentIntent,
|
||||||
advanceGoal,
|
advanceGoal,
|
||||||
abandonSession,
|
abandonSession,
|
||||||
setGoalInput: selection.setGoalInput,
|
setGoalInput: selection.setGoalInput,
|
||||||
@@ -283,12 +284,11 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isFocusMode ? (
|
||||||
<SpaceFocusHudWidget
|
<SpaceFocusHudWidget
|
||||||
goal={selection.goalInput.trim()}
|
goal={selection.goalInput.trim()}
|
||||||
microStep={currentSession?.microStep ?? null}
|
microStep={currentSession?.microStep ?? null}
|
||||||
timerLabel={selection.selectedTimerLabel}
|
|
||||||
timeDisplay={resolvedTimeDisplay}
|
timeDisplay={resolvedTimeDisplay}
|
||||||
visible={isFocusMode}
|
|
||||||
hasActiveSession={Boolean(currentSession)}
|
hasActiveSession={Boolean(currentSession)}
|
||||||
playbackState={resolvedPlaybackState}
|
playbackState={resolvedPlaybackState}
|
||||||
sessionPhase={phase ?? 'focus'}
|
sessionPhase={phase ?? 'focus'}
|
||||||
@@ -305,12 +305,11 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
onRestartRequested={() => {
|
onRestartRequested={() => {
|
||||||
void controls.handleRestartRequested();
|
void controls.handleRestartRequested();
|
||||||
}}
|
}}
|
||||||
onExitRequested={() => {
|
|
||||||
void controls.handleExitRequested();
|
|
||||||
}}
|
|
||||||
onStatusMessage={pushStatusLine}
|
onStatusMessage={pushStatusLine}
|
||||||
|
onIntentUpdate={controls.handleIntentUpdate}
|
||||||
onGoalUpdate={controls.handleGoalAdvance}
|
onGoalUpdate={controls.handleGoalAdvance}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<FocusTopToast
|
<FocusTopToast
|
||||||
visible={isFocusMode && Boolean(activeStatus)}
|
visible={isFocusMode && Boolean(activeStatus)}
|
||||||
|
|||||||
Reference in New Issue
Block a user