fix(space): 정리된 intent hud와 리뷰 반영
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
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',
|
||||
open ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0',
|
||||
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-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}
|
||||
>
|
||||
<section className="pointer-events-auto w-[min(460px,94vw)] rounded-2xl border border-white/12 bg-black/26 px-3.5 py-3 text-white shadow-[0_14px_30px_rgba(2,6,23,0.28)] backdrop-blur-md">
|
||||
<header className="flex items-start justify-between gap-2">
|
||||
<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" />
|
||||
|
||||
<header className="relative flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white/92">{copy.space.goalComplete.title}</h3>
|
||||
<p className="mt-0.5 text-[11px] text-white/58">{copy.space.goalComplete.description}</p>
|
||||
<p className="text-[11px] font-medium tracking-[0.08em] text-white/38">목표 완료</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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-[11px] text-white/72 transition-colors hover:bg-white/[0.12]"
|
||||
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}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="mt-2.5 space-y-2.5" onSubmit={handleSubmit}>
|
||||
<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-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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRest}
|
||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||
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="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}
|
||||
</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 type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||
import { FloatingGoalWidget } from './FloatingGoalWidget';
|
||||
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
||||
import { IntentCapsule } from './IntentCapsule';
|
||||
import { NextMicroStepPrompt } from './NextMicroStepPrompt';
|
||||
import { RefocusSheet } from './RefocusSheet';
|
||||
|
||||
interface SpaceFocusHudWidgetProps {
|
||||
goal: string;
|
||||
microStep?: string | null;
|
||||
timerLabel: string;
|
||||
timeDisplay?: string;
|
||||
visible: boolean;
|
||||
hasActiveSession?: boolean;
|
||||
playbackState?: 'running' | 'paused';
|
||||
sessionPhase?: 'focus' | 'break' | null;
|
||||
@@ -21,7 +21,7 @@ interface SpaceFocusHudWidgetProps {
|
||||
onStartRequested?: () => void;
|
||||
onPauseRequested?: () => void;
|
||||
onRestartRequested?: () => void;
|
||||
onExitRequested?: () => void;
|
||||
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
|
||||
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
|
||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||
}
|
||||
@@ -29,9 +29,7 @@ interface SpaceFocusHudWidgetProps {
|
||||
export const SpaceFocusHudWidget = ({
|
||||
goal,
|
||||
microStep,
|
||||
timerLabel,
|
||||
timeDisplay,
|
||||
visible,
|
||||
hasActiveSession = false,
|
||||
playbackState = 'paused',
|
||||
sessionPhase = 'focus',
|
||||
@@ -42,15 +40,25 @@ export const SpaceFocusHudWidget = ({
|
||||
onStartRequested,
|
||||
onPauseRequested,
|
||||
onRestartRequested,
|
||||
onExitRequested,
|
||||
onIntentUpdate,
|
||||
onGoalUpdate,
|
||||
onStatusMessage,
|
||||
}: SpaceFocusHudWidgetProps) => {
|
||||
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 playbackStateRef = useRef<'running' | 'paused'>(playbackState);
|
||||
const resumePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
|
||||
const pausePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -62,44 +70,206 @@ export const SpaceFocusHudWidget = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !visibleRef.current && playbackState === 'running') {
|
||||
if (!visibleRef.current && playbackState === 'running') {
|
||||
onStatusMessage({
|
||||
message: copy.space.focusHud.goalToast(normalizedGoal),
|
||||
});
|
||||
}
|
||||
|
||||
visibleRef.current = visible;
|
||||
}, [normalizedGoal, onStatusMessage, playbackState, visible]);
|
||||
visibleRef.current = true;
|
||||
}, [normalizedGoal, onStatusMessage, playbackState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playbackStateRef.current === 'paused' && playbackState === 'running' && visible) {
|
||||
if (resumePlaybackStateRef.current === 'paused' && playbackState === 'running') {
|
||||
onStatusMessage({
|
||||
message: copy.space.focusHud.goalToast(normalizedGoal),
|
||||
});
|
||||
}
|
||||
|
||||
playbackStateRef.current = playbackState;
|
||||
}, [normalizedGoal, onStatusMessage, playbackState, visible]);
|
||||
resumePlaybackStateRef.current = playbackState;
|
||||
}, [normalizedGoal, onStatusMessage, playbackState]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
const openRefocus = useCallback((field: 'goal' | 'microStep' = 'goal') => {
|
||||
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 = () => {
|
||||
setIntentError(null);
|
||||
setRefocusOpen(false);
|
||||
setMicroStepPromptOpen(false);
|
||||
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 (
|
||||
<>
|
||||
<FloatingGoalWidget
|
||||
goal={goal}
|
||||
microStep={microStep}
|
||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||
hasActiveSession={hasActiveSession}
|
||||
sessionPhase={sessionPhase}
|
||||
/>
|
||||
<div className="pointer-events-none fixed left-6 top-6 z-20 w-[min(26rem,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()}
|
||||
onMicroStepDone={() => {
|
||||
if (!normalizedMicroStep) {
|
||||
openRefocus('microStep');
|
||||
return;
|
||||
}
|
||||
|
||||
setIntentError(null);
|
||||
setRefocusOpen(false);
|
||||
setMicroStepPromptOpen(true);
|
||||
}}
|
||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||
/>
|
||||
<RefocusSheet
|
||||
open={isRefocusOpen}
|
||||
goalDraft={draftGoal}
|
||||
microStepDraft={draftMicroStep}
|
||||
autoFocusField={autoFocusField}
|
||||
isSaving={isSavingIntent}
|
||||
error={intentError}
|
||||
onGoalChange={setDraftGoal}
|
||||
onMicroStepChange={setDraftMicroStep}
|
||||
onClose={() => {
|
||||
if (isSavingIntent) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIntentError(null);
|
||||
setRefocusOpen(false);
|
||||
}}
|
||||
onSubmit={() => {
|
||||
void handleRefocusSubmit();
|
||||
}}
|
||||
/>
|
||||
<NextMicroStepPrompt
|
||||
open={isMicroStepPromptOpen}
|
||||
isSubmitting={isSavingIntent}
|
||||
error={intentError}
|
||||
onKeepGoalOnly={() => {
|
||||
void handleKeepGoalOnly();
|
||||
}}
|
||||
onDefineNext={handleDefineNextMicroStep}
|
||||
/>
|
||||
<GoalCompleteSheet
|
||||
open={sheetOpen}
|
||||
currentGoal={goal}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onRest={() => {
|
||||
setSheetOpen(false);
|
||||
|
||||
if (restReminderTimerRef.current) {
|
||||
window.clearTimeout(restReminderTimerRef.current);
|
||||
}
|
||||
|
||||
restReminderTimerRef.current = window.setTimeout(() => {
|
||||
onStatusMessage({ message: copy.space.focusHud.restReminder });
|
||||
restReminderTimerRef.current = null;
|
||||
}, 5 * 60 * 1000);
|
||||
}}
|
||||
onConfirm={(nextGoal) => {
|
||||
return Promise.resolve(onGoalUpdate(nextGoal));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SpaceTimerHudWidget
|
||||
timerLabel={timerLabel}
|
||||
timeDisplay={timeDisplay}
|
||||
isImmersionMode
|
||||
hasActiveSession={hasActiveSession}
|
||||
@@ -114,26 +284,6 @@ export const SpaceFocusHudWidget = ({
|
||||
onPauseClick={onPauseRequested}
|
||||
onResetClick={onRestartRequested}
|
||||
/>
|
||||
<GoalCompleteSheet
|
||||
open={sheetOpen}
|
||||
currentGoal={goal}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onRest={() => {
|
||||
setSheetOpen(false);
|
||||
|
||||
if (restReminderTimerRef.current) {
|
||||
window.clearTimeout(restReminderTimerRef.current);
|
||||
}
|
||||
|
||||
restReminderTimerRef.current = window.setTimeout(() => {
|
||||
onStatusMessage({ message: copy.space.focusHud.restReminder });
|
||||
restReminderTimerRef.current = null;
|
||||
}, 5 * 60 * 1000);
|
||||
}}
|
||||
onConfirm={(nextGoal) => {
|
||||
return Promise.resolve(onGoalUpdate(nextGoal));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user