chore(web): 사용하지 않는 legacy 위젯 정리
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
export * from './ui/ControlCenterSheetWidget';
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import type { PlanTier } from '@/entities/plan';
|
|
||||||
import {
|
|
||||||
PRO_FEATURE_CARDS,
|
|
||||||
} from '@/entities/plan';
|
|
||||||
import type { SceneTheme } from '@/entities/scene';
|
|
||||||
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
|
|
||||||
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
|
||||||
import { useDragScroll } from '@/shared/lib/useDragScroll';
|
|
||||||
import { Toggle } from '@/shared/ui';
|
|
||||||
|
|
||||||
interface ControlCenterSheetWidgetProps {
|
|
||||||
plan: PlanTier;
|
|
||||||
scenes: SceneTheme[];
|
|
||||||
sceneAssetMap?: SceneAssetMap;
|
|
||||||
selectedSceneId: string;
|
|
||||||
selectedTimerLabel: string;
|
|
||||||
selectedSoundPresetId: string;
|
|
||||||
sceneRecommendedSoundLabel: string;
|
|
||||||
sceneRecommendedTimerLabel: string;
|
|
||||||
timerPresets: TimerPreset[];
|
|
||||||
autoHideControls: boolean;
|
|
||||||
onAutoHideControlsChange: (next: boolean) => void;
|
|
||||||
onSelectScene: (sceneId: string) => void;
|
|
||||||
onSelectTimer: (timerLabel: string) => void;
|
|
||||||
onSelectSound: (presetId: string) => void;
|
|
||||||
onSelectProFeature: (featureId: string) => void;
|
|
||||||
onLockedClick: (source: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SectionTitle = ({ title, description }: { title: string; description: string }) => {
|
|
||||||
return (
|
|
||||||
<header className="flex items-end justify-between gap-2">
|
|
||||||
<h4 className="text-sm font-semibold text-white/90">{title}</h4>
|
|
||||||
<p className="text-[11px] text-white/56">{description}</p>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ControlCenterSheetWidget = ({
|
|
||||||
plan,
|
|
||||||
scenes,
|
|
||||||
sceneAssetMap,
|
|
||||||
selectedSceneId,
|
|
||||||
selectedTimerLabel,
|
|
||||||
selectedSoundPresetId,
|
|
||||||
sceneRecommendedSoundLabel,
|
|
||||||
sceneRecommendedTimerLabel,
|
|
||||||
timerPresets,
|
|
||||||
autoHideControls,
|
|
||||||
onAutoHideControlsChange,
|
|
||||||
onSelectScene,
|
|
||||||
onSelectTimer,
|
|
||||||
onSelectSound,
|
|
||||||
onSelectProFeature,
|
|
||||||
onLockedClick,
|
|
||||||
}: ControlCenterSheetWidgetProps) => {
|
|
||||||
const { controlCenter } = copy.space;
|
|
||||||
const reducedMotion = useReducedMotion();
|
|
||||||
const isPro = plan === 'pro';
|
|
||||||
const interactiveMotionClass = reducedMotion
|
|
||||||
? 'transition-none'
|
|
||||||
: 'transition-[transform,background-color,border-color,box-shadow,color,opacity] duration-[220ms] ease-out';
|
|
||||||
const colorMotionClass = reducedMotion
|
|
||||||
? 'transition-none'
|
|
||||||
: 'transition-colors duration-[220ms] ease-out';
|
|
||||||
|
|
||||||
const selectedScene = useMemo(() => {
|
|
||||||
return scenes.find((scene) => scene.id === selectedSceneId) ?? scenes[0];
|
|
||||||
}, [scenes, selectedSceneId]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
containerRef: sceneContainerRef,
|
|
||||||
events: sceneDragEvents,
|
|
||||||
isDragging: isSceneDragging,
|
|
||||||
shouldSuppressClick: shouldSuppressSceneClick,
|
|
||||||
} = useDragScroll();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
|
|
||||||
<SectionTitle title={controlCenter.sectionTitles.background} description={selectedScene?.name ?? copy.common.defaultBackground} />
|
|
||||||
<div
|
|
||||||
ref={sceneContainerRef}
|
|
||||||
{...sceneDragEvents}
|
|
||||||
className={cn(
|
|
||||||
'-mx-1 flex gap-2.5 overflow-x-auto px-1 pb-1.5 scrollbar-none',
|
|
||||||
isSceneDragging ? 'cursor-grabbing' : 'cursor-grab',
|
|
||||||
reducedMotion ? '' : 'scroll-smooth',
|
|
||||||
)}
|
|
||||||
style={{ scrollBehavior: isSceneDragging ? 'auto' : (reducedMotion ? 'auto' : 'smooth') }}
|
|
||||||
>
|
|
||||||
{scenes.slice(0, 6).map((scene) => {
|
|
||||||
const selected = scene.id === selectedSceneId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={scene.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!shouldSuppressSceneClick) {
|
|
||||||
onSelectScene(scene.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'relative h-24 w-[130px] shrink-0 overflow-hidden rounded-xl border text-left',
|
|
||||||
interactiveMotionClass,
|
|
||||||
reducedMotion ? '' : 'hover:-translate-y-0.5',
|
|
||||||
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
|
|
||||||
isSceneDragging && 'pointer-events-none'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-0 bg-cover bg-center"
|
|
||||||
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
|
||||||
/>
|
|
||||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
|
|
||||||
<div className="absolute inset-x-2 bottom-2 min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-white/90">{scene.name}</p>
|
|
||||||
<p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
|
|
||||||
<SectionTitle title={controlCenter.sectionTitles.time} description={selectedTimerLabel} />
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{timerPresets.slice(0, 3).map((preset) => {
|
|
||||||
const selected = preset.label === selectedTimerLabel;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSelectTimer(preset.label);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'relative rounded-xl border px-3 py-2.5 text-xs',
|
|
||||||
colorMotionClass,
|
|
||||||
selected
|
|
||||||
? 'border-sky-200/42 bg-sky-200/16 text-white'
|
|
||||||
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
|
|
||||||
<SectionTitle
|
|
||||||
title={controlCenter.sectionTitles.sound}
|
|
||||||
description={SOUND_PRESETS.find((preset) => preset.id === selectedSoundPresetId)?.label ?? copy.common.default}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{SOUND_PRESETS.slice(0, 6).map((preset) => {
|
|
||||||
const selected = preset.id === selectedSoundPresetId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSelectSound(preset.id);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'relative rounded-xl border px-3 py-2 text-[11px]',
|
|
||||||
colorMotionClass,
|
|
||||||
selected
|
|
||||||
? 'border-sky-200/42 bg-sky-200/16 text-white'
|
|
||||||
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="space-y-1.5 rounded-xl border border-white/12 bg-white/[0.03] px-3 py-2.5">
|
|
||||||
<p className="text-[11px] text-white/58">{controlCenter.recommendation(sceneRecommendedSoundLabel, sceneRecommendedTimerLabel)}</p>
|
|
||||||
<p className="text-[10px] text-white/48">{controlCenter.recommendationHint}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 p-3 backdrop-blur-md">
|
|
||||||
<SectionTitle title={controlCenter.sectionTitles.packs} description={controlCenter.packsDescription} />
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{PRO_FEATURE_CARDS.map((feature) => {
|
|
||||||
const locked = !isPro;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={feature.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (locked) {
|
|
||||||
onLockedClick(feature.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectProFeature(feature.id);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center justify-between rounded-xl border border-white/14 bg-white/[0.03] px-3 py-2 text-left transition-colors hover:bg-white/[0.08]"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-white/88">{feature.name}</p>
|
|
||||||
<p className="mt-0.5 text-[10px] text-white/56">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
{locked ? <span className="text-xs text-white/70">🔒</span> : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 px-3 py-2.5 backdrop-blur-md">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[11px] text-white/72">{controlCenter.autoHideTitle}</p>
|
|
||||||
<p className="mt-0.5 text-[10px] text-white/52">{controlCenter.autoHideDescription}</p>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
checked={autoHideControls}
|
|
||||||
onChange={onAutoHideControlsChange}
|
|
||||||
ariaLabel={controlCenter.autoHideAriaLabel}
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { RefObject } from 'react';
|
|
||||||
import type { FocusPlanItem } from '@/entities/focus-plan';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import { Modal } from '@/shared/ui';
|
|
||||||
import { FocusPlanListRow } from './FocusPlanListRow';
|
|
||||||
|
|
||||||
export type FocusPlanEditingState =
|
|
||||||
| {
|
|
||||||
mode: 'new';
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
mode: 'edit';
|
|
||||||
itemId: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
|
|
||||||
interface FocusPlanManageSheetProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
planItems: FocusPlanItem[];
|
|
||||||
selectedPlanItemId: string | null;
|
|
||||||
editingState: FocusPlanEditingState;
|
|
||||||
isSaving: boolean;
|
|
||||||
canAddMore: boolean;
|
|
||||||
isPro: boolean;
|
|
||||||
inputRef?: RefObject<HTMLInputElement | null>;
|
|
||||||
onClose: () => void;
|
|
||||||
onAddBlock: () => void;
|
|
||||||
onDraftChange: (value: string) => void;
|
|
||||||
onSelect: (item: FocusPlanItem) => void;
|
|
||||||
onEdit: (item: FocusPlanItem) => void;
|
|
||||||
onDelete: (itemId: string) => void;
|
|
||||||
onSave: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const manageSheetCopy = {
|
|
||||||
title: '블록 정리',
|
|
||||||
description: '집중에 들어간 뒤 이어갈 블록만 가볍게 남겨두세요.',
|
|
||||||
empty: '아직 저장된 블록이 없어요.',
|
|
||||||
addBlock: '+ 블록 추가',
|
|
||||||
placeholder: '예: 리뷰 코멘트 2개 정리',
|
|
||||||
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
|
||||||
maxBlocksReached: '최대 5개까지 정리해 둘 수 있어요.',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FocusPlanManageSheet = ({
|
|
||||||
isOpen,
|
|
||||||
planItems,
|
|
||||||
selectedPlanItemId,
|
|
||||||
editingState,
|
|
||||||
isSaving,
|
|
||||||
canAddMore,
|
|
||||||
isPro,
|
|
||||||
inputRef,
|
|
||||||
onClose,
|
|
||||||
onAddBlock,
|
|
||||||
onDraftChange,
|
|
||||||
onSelect,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
}: FocusPlanManageSheetProps) => {
|
|
||||||
const hasPendingEdit = editingState !== null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={manageSheetCopy.title}
|
|
||||||
description={manageSheetCopy.description}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="overflow-hidden rounded-[1.4rem] border border-brand-dark/10 bg-white/66">
|
|
||||||
<div className="divide-y divide-slate-200/78">
|
|
||||||
{planItems.length === 0 && editingState?.mode !== 'new' ? (
|
|
||||||
<div className="px-4 py-3.5 text-[15px] text-brand-dark/46 sm:px-5">
|
|
||||||
{manageSheetCopy.empty}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{planItems.map((item, index) => (
|
|
||||||
<FocusPlanListRow
|
|
||||||
key={item.id}
|
|
||||||
title={item.title}
|
|
||||||
isCurrent={index === 0}
|
|
||||||
isSelected={selectedPlanItemId === item.id}
|
|
||||||
isEditing={editingState?.mode === 'edit' && editingState.itemId === item.id}
|
|
||||||
draftValue={
|
|
||||||
editingState?.mode === 'edit' && editingState.itemId === item.id
|
|
||||||
? editingState.value
|
|
||||||
: item.title
|
|
||||||
}
|
|
||||||
isSaving={isSaving}
|
|
||||||
isBusy={isSaving || hasPendingEdit}
|
|
||||||
inputRef={editingState?.mode === 'edit' && editingState.itemId === item.id ? inputRef : undefined}
|
|
||||||
onDraftChange={onDraftChange}
|
|
||||||
onSelect={() => onSelect(item)}
|
|
||||||
onEdit={() => onEdit(item)}
|
|
||||||
onDelete={() => onDelete(item.id)}
|
|
||||||
onSave={onSave}
|
|
||||||
onCancel={onCancel}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{editingState?.mode === 'new' ? (
|
|
||||||
<FocusPlanListRow
|
|
||||||
title=""
|
|
||||||
isCurrent={planItems.length === 0}
|
|
||||||
isEditing
|
|
||||||
draftValue={editingState.value}
|
|
||||||
isSaving={isSaving}
|
|
||||||
isBusy={isSaving}
|
|
||||||
inputRef={inputRef}
|
|
||||||
placeholder={manageSheetCopy.placeholder}
|
|
||||||
onDraftChange={onDraftChange}
|
|
||||||
onSelect={() => {}}
|
|
||||||
onEdit={() => {}}
|
|
||||||
onDelete={() => {}}
|
|
||||||
onSave={onSave}
|
|
||||||
onCancel={onCancel}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pl-1">
|
|
||||||
{isPro && !canAddMore ? (
|
|
||||||
<p className="text-sm text-brand-dark/46">{manageSheetCopy.maxBlocksReached}</p>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAddBlock}
|
|
||||||
disabled={hasPendingEdit || isSaving}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-2 text-[15px] font-medium text-brand-primary transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/18 disabled:cursor-not-allowed disabled:opacity-45',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span>{manageSheetCopy.addBlock}</span>
|
|
||||||
{!isPro && !canAddMore ? (
|
|
||||||
<span className="rounded-full bg-brand-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-brand-primary">
|
|
||||||
{manageSheetCopy.freeUpgradeLabel}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import {
|
|
||||||
HUD_OPTION_CHEVRON,
|
|
||||||
HUD_OPTION_ROW,
|
|
||||||
HUD_OPTION_ROW_BREAK,
|
|
||||||
HUD_OPTION_ROW_PRIMARY,
|
|
||||||
HUD_REVEAL_BASE,
|
|
||||||
HUD_REVEAL_HIDDEN,
|
|
||||||
HUD_REVEAL_RETURN_BREAK,
|
|
||||||
HUD_REVEAL_RETURN_FOCUS,
|
|
||||||
HUD_RETURN_BODY,
|
|
||||||
HUD_RETURN_TITLE,
|
|
||||||
HUD_TRAY_CONTENT,
|
|
||||||
HUD_TRAY_HAIRLINE_BREAK,
|
|
||||||
HUD_TRAY_HAIRLINE,
|
|
||||||
HUD_TRAY_LAYER_BREAK,
|
|
||||||
HUD_TRAY_LAYER,
|
|
||||||
HUD_TRAY_SHELL_BREAK,
|
|
||||||
HUD_TRAY_SHELL,
|
|
||||||
} from './overlayStyles';
|
|
||||||
|
|
||||||
interface ReturnPromptProps {
|
|
||||||
open: boolean;
|
|
||||||
mode: 'focus' | 'break';
|
|
||||||
isBusy: boolean;
|
|
||||||
onContinue: () => void;
|
|
||||||
onRefocus: () => void;
|
|
||||||
onRest?: () => void;
|
|
||||||
onNextGoal?: () => void;
|
|
||||||
onFinish: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReturnPrompt = ({
|
|
||||||
open,
|
|
||||||
mode,
|
|
||||||
isBusy,
|
|
||||||
onContinue,
|
|
||||||
onRefocus,
|
|
||||||
onRest,
|
|
||||||
onNextGoal,
|
|
||||||
onFinish,
|
|
||||||
}: ReturnPromptProps) => {
|
|
||||||
const isBreakReturn = mode === 'break';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
HUD_REVEAL_BASE,
|
|
||||||
open
|
|
||||||
? isBreakReturn
|
|
||||||
? HUD_REVEAL_RETURN_BREAK
|
|
||||||
: HUD_REVEAL_RETURN_FOCUS
|
|
||||||
: HUD_REVEAL_HIDDEN,
|
|
||||||
)}
|
|
||||||
aria-hidden={!open}
|
|
||||||
>
|
|
||||||
<section className={cn(isBreakReturn ? HUD_TRAY_SHELL_BREAK : HUD_TRAY_SHELL)}>
|
|
||||||
<div aria-hidden className={isBreakReturn ? HUD_TRAY_LAYER_BREAK : HUD_TRAY_LAYER} />
|
|
||||||
<div aria-hidden className={isBreakReturn ? HUD_TRAY_HAIRLINE_BREAK : HUD_TRAY_HAIRLINE} />
|
|
||||||
|
|
||||||
<div className={HUD_TRAY_CONTENT}>
|
|
||||||
<p className={cn(
|
|
||||||
'text-[11px] font-medium tracking-[0.12em]',
|
|
||||||
isBreakReturn ? 'text-emerald-100/54' : 'text-white/42',
|
|
||||||
)}>
|
|
||||||
{copy.space.focusHud.returnPromptEyebrow}
|
|
||||||
</p>
|
|
||||||
<h3 className={cn(HUD_RETURN_TITLE, isBreakReturn ? 'text-white/96' : undefined)}>
|
|
||||||
{isBreakReturn
|
|
||||||
? copy.space.focusHud.returnPromptBreakTitle
|
|
||||||
: copy.space.focusHud.returnPromptFocusTitle}
|
|
||||||
</h3>
|
|
||||||
<p className={cn(HUD_RETURN_BODY, isBreakReturn ? 'text-emerald-50/62' : undefined)}>
|
|
||||||
{isBreakReturn
|
|
||||||
? copy.space.focusHud.returnPromptBreakDescription
|
|
||||||
: copy.space.focusHud.returnPromptFocusDescription}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-2.5 border-t border-white/8 pt-4">
|
|
||||||
{isBreakReturn ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRest}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_BREAK)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 max-w-[20.5rem]">
|
|
||||||
<p className="text-[14px] font-semibold leading-[1.35] tracking-[-0.01em] text-white/92">
|
|
||||||
{copy.space.focusHud.returnPromptRest}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] leading-[1.55] text-emerald-50/56">
|
|
||||||
{copy.space.focusHud.returnPromptRestHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px] text-emerald-100/34 group-hover:text-emerald-100/58')}>→</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNextGoal}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={HUD_OPTION_ROW}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 max-w-[20.5rem]">
|
|
||||||
<p className="text-[14px] font-medium leading-[1.35] tracking-[-0.01em] text-white/82">
|
|
||||||
{copy.space.focusHud.returnPromptNext}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] leading-[1.55] text-white/46">
|
|
||||||
{copy.space.focusHud.returnPromptNextHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px]')}>→</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRefocus}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={HUD_OPTION_ROW}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 max-w-[20.5rem]">
|
|
||||||
<p className="text-[14px] font-medium leading-[1.35] tracking-[-0.01em] text-white/82">
|
|
||||||
{copy.space.focusHud.returnPromptRefocus}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] leading-[1.55] text-white/46">
|
|
||||||
{copy.space.focusHud.returnPromptRefocusHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px]')}>→</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onContinue}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 max-w-[20.5rem]">
|
|
||||||
<p className="text-[14px] font-semibold leading-[1.35] tracking-[-0.01em] text-white/92">
|
|
||||||
{copy.space.focusHud.returnPromptContinue}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] leading-[1.55] text-white/48">
|
|
||||||
{copy.space.focusHud.returnPromptContinueHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px]')}>→</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRefocus}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={HUD_OPTION_ROW}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 max-w-[20.5rem]">
|
|
||||||
<p className="text-[14px] font-medium leading-[1.35] tracking-[-0.01em] text-white/82">
|
|
||||||
{copy.space.focusHud.returnPromptRefocus}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] leading-[1.55] text-white/46">
|
|
||||||
{copy.space.focusHud.returnPromptRefocusHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px]')}>→</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 border-t border-white/8 pt-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onFinish}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={cn(
|
|
||||||
'text-[12px] font-medium tracking-[0.08em] underline underline-offset-4 transition-colors disabled:cursor-default disabled:no-underline',
|
|
||||||
isBreakReturn
|
|
||||||
? 'text-emerald-50/68 decoration-emerald-100/18 hover:text-emerald-50/92 hover:decoration-emerald-100/32 disabled:text-emerald-50/28'
|
|
||||||
: 'text-white/58 decoration-white/12 hover:text-white/82 hover:decoration-white/24 disabled:text-white/26',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{copy.space.focusHud.returnPromptFinish}
|
|
||||||
</button>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'mt-1.5 max-w-[20.5rem] text-[12px] leading-[1.55]',
|
|
||||||
isBreakReturn ? 'text-emerald-50/46' : 'text-white/42',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{copy.space.focusHud.returnPromptFinishHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './ui/SpaceSideSheet';
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
|
||||||
|
|
||||||
interface SpaceSideSheetProps {
|
|
||||||
open: boolean;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
footer?: ReactNode;
|
|
||||||
widthClassName?: string;
|
|
||||||
dismissible?: boolean;
|
|
||||||
headerAction?: ReactNode;
|
|
||||||
overlayClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SpaceSideSheet = ({
|
|
||||||
open,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
footer,
|
|
||||||
widthClassName,
|
|
||||||
dismissible = true,
|
|
||||||
headerAction,
|
|
||||||
overlayClassName,
|
|
||||||
}: SpaceSideSheetProps) => {
|
|
||||||
const reducedMotion = useReducedMotion();
|
|
||||||
const transitionMs = reducedMotion ? 0 : 260;
|
|
||||||
const closeTimerRef = useRef<number | null>(null);
|
|
||||||
const [shouldRender, setShouldRender] = useState(open);
|
|
||||||
const [visible, setVisible] = useState(open);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (closeTimerRef.current) {
|
|
||||||
window.clearTimeout(closeTimerRef.current);
|
|
||||||
closeTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
let nestedRaf = 0;
|
|
||||||
const raf = window.requestAnimationFrame(() => {
|
|
||||||
setShouldRender(true);
|
|
||||||
nestedRaf = window.requestAnimationFrame(() => {
|
|
||||||
setVisible(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(raf);
|
|
||||||
window.cancelAnimationFrame(nestedRaf);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideRaf = window.requestAnimationFrame(() => {
|
|
||||||
setVisible(false);
|
|
||||||
});
|
|
||||||
closeTimerRef.current = window.setTimeout(() => {
|
|
||||||
setShouldRender(false);
|
|
||||||
closeTimerRef.current = null;
|
|
||||||
}, transitionMs);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(hideRaf);
|
|
||||||
if (closeTimerRef.current) {
|
|
||||||
window.clearTimeout(closeTimerRef.current);
|
|
||||||
closeTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [open, transitionMs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleEscape);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleEscape);
|
|
||||||
};
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{dismissible ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={copy.modal.closeAriaLabel}
|
|
||||||
onClick={onClose}
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 z-40 bg-slate-950/14 backdrop-blur-[1px] transition-opacity',
|
|
||||||
reducedMotion ? 'duration-0' : 'duration-[240ms]',
|
|
||||||
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
|
||||||
overlayClassName,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 z-40 bg-slate-950/8 backdrop-blur-[1px] transition-opacity',
|
|
||||||
reducedMotion ? 'duration-0' : 'duration-[240ms]',
|
|
||||||
visible ? 'opacity-100' : 'opacity-0',
|
|
||||||
overlayClassName,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-y-0 right-0 z-50 p-2 transition-opacity sm:p-3',
|
|
||||||
reducedMotion ? 'duration-0' : 'duration-[260ms]',
|
|
||||||
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
|
||||||
widthClassName ?? 'w-[min(360px,92vw)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-full flex-col overflow-hidden rounded-3xl border border-white/12 bg-black/24 text-white shadow-[0_18px_52px_rgba(2,6,23,0.34)] backdrop-blur-xl transition-transform',
|
|
||||||
reducedMotion ? 'duration-0' : 'duration-[260ms]',
|
|
||||||
visible || reducedMotion ? 'translate-x-0' : 'translate-x-8',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header className="flex items-start justify-between gap-3 border-b border-white/7 px-4 py-3 sm:px-5">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-[1.05rem] font-semibold tracking-tight text-white">{title}</h2>
|
|
||||||
{subtitle ? <p className="mt-1 text-[11px] text-white/56">{subtitle}</p> : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{headerAction}
|
|
||||||
{dismissible ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label={copy.modal.closeButton}
|
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/14 bg-white/6 text-[12px] text-white/72 transition-colors hover:bg-white/12 hover:text-white"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5 sm:px-5">{children}</div>
|
|
||||||
|
|
||||||
{footer ? <footer className="border-t border-white/8 bg-white/[0.02] px-4 py-3 sm:px-5">{footer}</footer> : null}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './ui/SpaceTimerHudWidget';
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import {
|
|
||||||
RECOVERY_30S_MODE_LABEL,
|
|
||||||
Restart30sAction,
|
|
||||||
useRestart30s,
|
|
||||||
} from '@/features/restart-30s';
|
|
||||||
|
|
||||||
interface SpaceTimerHudWidgetProps {
|
|
||||||
className?: string;
|
|
||||||
hasActiveSession?: boolean;
|
|
||||||
sessionPhase?: 'focus' | 'break' | null;
|
|
||||||
playbackState?: 'running' | 'paused' | null;
|
|
||||||
isControlsDisabled?: boolean;
|
|
||||||
canStart?: boolean;
|
|
||||||
canPause?: boolean;
|
|
||||||
canReset?: boolean;
|
|
||||||
onStartClick?: () => void;
|
|
||||||
onPauseClick?: () => void;
|
|
||||||
onResetClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HUD_ACTIONS = copy.space.timerHud.actions;
|
|
||||||
|
|
||||||
export const SpaceTimerHudWidget = ({
|
|
||||||
className,
|
|
||||||
hasActiveSession = false,
|
|
||||||
sessionPhase = 'focus',
|
|
||||||
playbackState = 'paused',
|
|
||||||
isControlsDisabled = false,
|
|
||||||
canStart = true,
|
|
||||||
canPause = false,
|
|
||||||
canReset = false,
|
|
||||||
onStartClick,
|
|
||||||
onPauseClick,
|
|
||||||
onResetClick,
|
|
||||||
}: SpaceTimerHudWidgetProps) => {
|
|
||||||
const { isBreatheMode, triggerRestart } = useRestart30s();
|
|
||||||
const isBreakPhase = hasActiveSession && sessionPhase === 'break';
|
|
||||||
const modeLabel = isBreatheMode
|
|
||||||
? RECOVERY_30S_MODE_LABEL
|
|
||||||
: !hasActiveSession
|
|
||||||
? copy.space.timerHud.readyMode
|
|
||||||
: sessionPhase === 'break'
|
|
||||||
? copy.space.timerHud.breakMode
|
|
||||||
: copy.space.timerHud.focusMode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 transition-opacity duration-500',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 2.5rem)' }}
|
|
||||||
>
|
|
||||||
<div className="relative pointer-events-auto opacity-40 hover:opacity-100 transition-opacity duration-300">
|
|
||||||
<section
|
|
||||||
className={cn(
|
|
||||||
'relative z-10 flex h-[3.5rem] items-center gap-4 overflow-hidden rounded-full pl-6 pr-4 transition-colors',
|
|
||||||
isBreakPhase
|
|
||||||
? 'border border-emerald-200/10 bg-[rgba(10,20,18,0.3)] backdrop-blur-2xl shadow-2xl'
|
|
||||||
: 'border border-white/5 bg-black/40 backdrop-blur-2xl shadow-2xl hover:border-white/10'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 pr-2 border-r border-white/10">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-[10px] font-bold uppercase tracking-[0.2em] opacity-80',
|
|
||||||
sessionPhase === 'break' ? 'text-emerald-300' : 'text-white/60'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{modeLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{HUD_ACTIONS.map((action) => {
|
|
||||||
const isStartAction = action.id === 'start';
|
|
||||||
const isPauseAction = action.id === 'pause';
|
|
||||||
const isResetAction = action.id === 'reset';
|
|
||||||
const isDisabled =
|
|
||||||
isControlsDisabled ||
|
|
||||||
(isStartAction ? !canStart : isPauseAction ? !canPause : !canReset);
|
|
||||||
const isHighlighted =
|
|
||||||
(isStartAction && playbackState !== 'running') ||
|
|
||||||
(isPauseAction && playbackState === 'running');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={action.id}
|
|
||||||
type="button"
|
|
||||||
title={action.label}
|
|
||||||
aria-pressed={isHighlighted}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={() => {
|
|
||||||
if (isStartAction) onStartClick?.();
|
|
||||||
if (isPauseAction) onPauseClick?.();
|
|
||||||
if (isResetAction) onResetClick?.();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-9 w-9 items-center justify-center rounded-full text-sm transition-all duration-300 ease-out focus-visible:outline-none focus-visible:ring-2 active:scale-95 disabled:cursor-not-allowed disabled:opacity-20',
|
|
||||||
isBreakPhase
|
|
||||||
? 'text-white/70 hover:bg-emerald-100/10 hover:text-white'
|
|
||||||
: 'text-white/60 hover:bg-white/10 hover:text-white',
|
|
||||||
isHighlighted
|
|
||||||
? isBreakPhase
|
|
||||||
? 'bg-emerald-100/10 text-white shadow-sm'
|
|
||||||
: 'bg-white/10 text-white shadow-sm'
|
|
||||||
: '',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span aria-hidden>{action.icon}</span>
|
|
||||||
<span className="sr-only">{action.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<Restart30sAction
|
|
||||||
onTrigger={triggerRestart}
|
|
||||||
className="h-9 w-9 ml-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './model/types';
|
|
||||||
export * from './ui/SpaceToolsDockWidget';
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { PRO_LOCKED_SOUND_IDS } from '@/entities/plan';
|
|
||||||
import type { SoundPreset } from '@/entities/session';
|
|
||||||
|
|
||||||
const QUICK_SOUND_FALLBACK_IDS = ['rain-focus', 'deep-white', 'silent'] as const;
|
|
||||||
|
|
||||||
const isQuickAvailablePreset = (preset: SoundPreset | undefined): preset is SoundPreset => {
|
|
||||||
if (!preset) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !PRO_LOCKED_SOUND_IDS.includes(preset.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getQuickSoundPresets = (presets: SoundPreset[]) => {
|
|
||||||
return QUICK_SOUND_FALLBACK_IDS
|
|
||||||
.map((presetId) => presets.find((preset) => preset.id === presetId))
|
|
||||||
.filter(isQuickAvailablePreset)
|
|
||||||
.slice(0, 3);
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export type SpaceAnchorPopoverId = 'sound' | 'notes';
|
|
||||||
export type SpaceUtilityPanelId = 'control-center' | 'inbox' | 'manage-plan' | 'paywall';
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
||||||
import { usePlanTier } from '@/entities/plan';
|
|
||||||
import type { RecentThought } from '@/entities/session';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
|
||||||
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
|
|
||||||
|
|
||||||
interface UseSpaceToolsDockHandlersParams {
|
|
||||||
setIdle: (idle: boolean) => void;
|
|
||||||
setOpenPopover: (popover: SpaceAnchorPopoverId | null) => void;
|
|
||||||
setUtilityPanel: (panel: SpaceUtilityPanelId | null) => void;
|
|
||||||
onCaptureThought: (note: string) => RecentThought | null;
|
|
||||||
onDeleteThought: (thoughtId: string) => RecentThought | null;
|
|
||||||
onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
|
|
||||||
onRestoreThought: (thought: RecentThought) => void;
|
|
||||||
onRestoreThoughts: (thoughts: RecentThought[]) => void;
|
|
||||||
onClearInbox: () => RecentThought[];
|
|
||||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
|
||||||
onSetSoundVolume: (volume: number) => void;
|
|
||||||
onSetSoundMuted: (muted: boolean) => void;
|
|
||||||
soundVolume: number;
|
|
||||||
isSoundMuted: boolean;
|
|
||||||
showVolumeFeedback: (volume: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSpaceToolsDockHandlers = ({
|
|
||||||
setIdle,
|
|
||||||
setOpenPopover,
|
|
||||||
setUtilityPanel,
|
|
||||||
onCaptureThought,
|
|
||||||
onDeleteThought,
|
|
||||||
onSetThoughtCompleted,
|
|
||||||
onRestoreThought,
|
|
||||||
onRestoreThoughts,
|
|
||||||
onClearInbox,
|
|
||||||
onStatusMessage,
|
|
||||||
onSetSoundVolume,
|
|
||||||
onSetSoundMuted,
|
|
||||||
soundVolume,
|
|
||||||
isSoundMuted,
|
|
||||||
showVolumeFeedback,
|
|
||||||
}: UseSpaceToolsDockHandlersParams) => {
|
|
||||||
const { toolsDock } = copy.space;
|
|
||||||
const [noteDraft, setNoteDraft] = useState('');
|
|
||||||
const { plan, setPlan } = usePlanTier();
|
|
||||||
|
|
||||||
const openUtilityPanel = useCallback((panel: SpaceUtilityPanelId) => {
|
|
||||||
setIdle(false);
|
|
||||||
setOpenPopover(null);
|
|
||||||
setUtilityPanel(panel);
|
|
||||||
}, [setIdle, setOpenPopover, setUtilityPanel]);
|
|
||||||
|
|
||||||
const handleNoteSubmit = useCallback(() => {
|
|
||||||
const trimmedNote = noteDraft.trim();
|
|
||||||
|
|
||||||
if (!trimmedNote) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const addedThought = onCaptureThought(trimmedNote);
|
|
||||||
|
|
||||||
if (!addedThought) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setNoteDraft('');
|
|
||||||
onStatusMessage({
|
|
||||||
message: toolsDock.inboxSaved,
|
|
||||||
durationMs: 4200,
|
|
||||||
priority: 'undo',
|
|
||||||
action: {
|
|
||||||
label: toolsDock.undo,
|
|
||||||
onClick: () => {
|
|
||||||
const removed = onDeleteThought(addedThought.id);
|
|
||||||
|
|
||||||
if (!removed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatusMessage({ message: toolsDock.inboxSaveUndone });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [noteDraft, onCaptureThought, onDeleteThought, onStatusMessage, toolsDock.inboxSaved, toolsDock.inboxSaveUndone, toolsDock.undo]);
|
|
||||||
|
|
||||||
const handleInboxComplete = useCallback((thought: RecentThought) => {
|
|
||||||
onSetThoughtCompleted(thought.id, !thought.isCompleted);
|
|
||||||
}, [onSetThoughtCompleted]);
|
|
||||||
|
|
||||||
const handleInboxDelete = useCallback((thought: RecentThought) => {
|
|
||||||
const removedThought = onDeleteThought(thought.id);
|
|
||||||
|
|
||||||
if (!removedThought) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatusMessage({
|
|
||||||
message: toolsDock.deleted,
|
|
||||||
durationMs: 4200,
|
|
||||||
priority: 'undo',
|
|
||||||
action: {
|
|
||||||
label: toolsDock.undo,
|
|
||||||
onClick: () => {
|
|
||||||
onRestoreThought(removedThought);
|
|
||||||
onStatusMessage({ message: toolsDock.deleteUndone });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [onDeleteThought, onRestoreThought, onStatusMessage, toolsDock.deleted, toolsDock.deleteUndone, toolsDock.undo]);
|
|
||||||
|
|
||||||
const handleInboxClear = useCallback(() => {
|
|
||||||
const snapshot = onClearInbox();
|
|
||||||
|
|
||||||
if (snapshot.length === 0) {
|
|
||||||
onStatusMessage({ message: toolsDock.emptyToClear });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatusMessage({
|
|
||||||
message: toolsDock.clearedAll,
|
|
||||||
durationMs: 4200,
|
|
||||||
priority: 'undo',
|
|
||||||
action: {
|
|
||||||
label: toolsDock.undo,
|
|
||||||
onClick: () => {
|
|
||||||
onRestoreThoughts(snapshot);
|
|
||||||
onStatusMessage({ message: toolsDock.restored });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [onClearInbox, onRestoreThoughts, onStatusMessage, toolsDock.clearedAll, toolsDock.emptyToClear, toolsDock.restored, toolsDock.undo]);
|
|
||||||
|
|
||||||
const handlePlanPillClick = useCallback(() => {
|
|
||||||
if (plan === 'pro') {
|
|
||||||
openUtilityPanel('manage-plan');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatusMessage({ message: toolsDock.normalPlanInfo });
|
|
||||||
}, [openUtilityPanel, onStatusMessage, plan, toolsDock.normalPlanInfo]);
|
|
||||||
|
|
||||||
const handleLockedClick = useCallback((source: string) => {
|
|
||||||
onStatusMessage({ message: toolsDock.proFeatureLocked(source) });
|
|
||||||
openUtilityPanel('paywall');
|
|
||||||
}, [onStatusMessage, openUtilityPanel, toolsDock]);
|
|
||||||
|
|
||||||
const handleSelectProFeature = useCallback((featureId: string) => {
|
|
||||||
const label =
|
|
||||||
featureId === 'daily-plan'
|
|
||||||
? toolsDock.featureLabels.dailyPlan
|
|
||||||
: featureId === 'rituals'
|
|
||||||
? toolsDock.featureLabels.rituals
|
|
||||||
: toolsDock.featureLabels.weeklyReview;
|
|
||||||
|
|
||||||
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
|
|
||||||
}, [onStatusMessage, toolsDock]);
|
|
||||||
|
|
||||||
const handleStartPro = useCallback(() => {
|
|
||||||
setPlan('pro');
|
|
||||||
onStatusMessage({ message: toolsDock.purchaseMock });
|
|
||||||
openUtilityPanel('control-center');
|
|
||||||
}, [onStatusMessage, openUtilityPanel, setPlan, toolsDock.purchaseMock]);
|
|
||||||
|
|
||||||
const handleVolumeChange = useCallback((nextVolume: number) => {
|
|
||||||
const clamped = Math.min(100, Math.max(0, nextVolume));
|
|
||||||
onSetSoundVolume(clamped);
|
|
||||||
|
|
||||||
if (isSoundMuted && clamped > 0) {
|
|
||||||
onSetSoundMuted(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
showVolumeFeedback(clamped);
|
|
||||||
}, [isSoundMuted, onSetSoundMuted, onSetSoundVolume, showVolumeFeedback]);
|
|
||||||
|
|
||||||
const handleVolumeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const step = event.shiftKey ? 10 : 5;
|
|
||||||
const delta = event.key === 'ArrowRight' ? step : -step;
|
|
||||||
handleVolumeChange(soundVolume + delta);
|
|
||||||
}, [handleVolumeChange, soundVolume]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
noteDraft,
|
|
||||||
setNoteDraft,
|
|
||||||
plan,
|
|
||||||
setPlan,
|
|
||||||
openUtilityPanel,
|
|
||||||
handleNoteSubmit,
|
|
||||||
handleInboxComplete,
|
|
||||||
handleInboxDelete,
|
|
||||||
handleInboxClear,
|
|
||||||
handlePlanPillClick,
|
|
||||||
handleLockedClick,
|
|
||||||
handleSelectProFeature,
|
|
||||||
handleStartPro,
|
|
||||||
handleVolumeChange,
|
|
||||||
handleVolumeKeyDown,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
|
|
||||||
|
|
||||||
interface UseSpaceToolsDockStateParams {
|
|
||||||
isFocusMode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSpaceToolsDockState = ({ isFocusMode }: UseSpaceToolsDockStateParams) => {
|
|
||||||
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
|
|
||||||
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
|
|
||||||
const [autoHideControls, setAutoHideControls] = useState(true);
|
|
||||||
const [isIdle, setIdle] = useState(false);
|
|
||||||
const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null);
|
|
||||||
const volumeFeedbackTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (volumeFeedbackTimerRef.current) {
|
|
||||||
window.clearTimeout(volumeFeedbackTimerRef.current);
|
|
||||||
volumeFeedbackTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isFocusMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rafId = window.requestAnimationFrame(() => {
|
|
||||||
setOpenPopover(null);
|
|
||||||
setUtilityPanel(null);
|
|
||||||
setIdle(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(rafId);
|
|
||||||
};
|
|
||||||
}, [isFocusMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFocusMode || openPopover || utilityPanel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timerId: number | null = null;
|
|
||||||
|
|
||||||
const armIdleTimer = () => {
|
|
||||||
if (timerId) {
|
|
||||||
window.clearTimeout(timerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerId = window.setTimeout(() => {
|
|
||||||
setIdle(true);
|
|
||||||
}, 3500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const wake = () => {
|
|
||||||
setIdle(false);
|
|
||||||
armIdleTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
armIdleTimer();
|
|
||||||
|
|
||||||
window.addEventListener('pointermove', wake);
|
|
||||||
window.addEventListener('keydown', wake);
|
|
||||||
window.addEventListener('pointerdown', wake);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timerId) {
|
|
||||||
window.clearTimeout(timerId);
|
|
||||||
}
|
|
||||||
window.removeEventListener('pointermove', wake);
|
|
||||||
window.removeEventListener('keydown', wake);
|
|
||||||
window.removeEventListener('pointerdown', wake);
|
|
||||||
};
|
|
||||||
}, [isFocusMode, openPopover, utilityPanel]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (utilityPanel !== 'control-center' || !autoHideControls) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timerId: number | null = null;
|
|
||||||
const closeDelayMs = 8000;
|
|
||||||
|
|
||||||
const armCloseTimer = () => {
|
|
||||||
if (timerId) {
|
|
||||||
window.clearTimeout(timerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerId = window.setTimeout(() => {
|
|
||||||
setUtilityPanel((current) => (current === 'control-center' ? null : current));
|
|
||||||
}, closeDelayMs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTimer = () => {
|
|
||||||
armCloseTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
armCloseTimer();
|
|
||||||
window.addEventListener('pointermove', resetTimer);
|
|
||||||
window.addEventListener('pointerdown', resetTimer);
|
|
||||||
window.addEventListener('keydown', resetTimer);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timerId) {
|
|
||||||
window.clearTimeout(timerId);
|
|
||||||
}
|
|
||||||
window.removeEventListener('pointermove', resetTimer);
|
|
||||||
window.removeEventListener('pointerdown', resetTimer);
|
|
||||||
window.removeEventListener('keydown', resetTimer);
|
|
||||||
};
|
|
||||||
}, [autoHideControls, utilityPanel]);
|
|
||||||
|
|
||||||
const showVolumeFeedback = (nextVolume: number) => {
|
|
||||||
setVolumeFeedback(`${nextVolume}%`);
|
|
||||||
|
|
||||||
if (volumeFeedbackTimerRef.current) {
|
|
||||||
window.clearTimeout(volumeFeedbackTimerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
volumeFeedbackTimerRef.current = window.setTimeout(() => {
|
|
||||||
setVolumeFeedback(null);
|
|
||||||
volumeFeedbackTimerRef.current = null;
|
|
||||||
}, 900);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
openPopover,
|
|
||||||
setOpenPopover,
|
|
||||||
utilityPanel,
|
|
||||||
setUtilityPanel,
|
|
||||||
autoHideControls,
|
|
||||||
setAutoHideControls,
|
|
||||||
isIdle,
|
|
||||||
setIdle,
|
|
||||||
volumeFeedback,
|
|
||||||
showVolumeFeedback,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
||||||
import type { SoundPreset } from '@/entities/session';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import type { SpaceAnchorPopoverId } from '../model/types';
|
|
||||||
import { FocusRightRail } from './FocusRightRail';
|
|
||||||
|
|
||||||
interface FocusModeAnchorsProps {
|
|
||||||
isFocusMode: boolean;
|
|
||||||
isIdle: boolean;
|
|
||||||
openPopover: SpaceAnchorPopoverId | null;
|
|
||||||
thoughtCount: number;
|
|
||||||
noteDraft: string;
|
|
||||||
selectedSoundLabel: string;
|
|
||||||
isSoundMuted: boolean;
|
|
||||||
soundVolume: number;
|
|
||||||
volumeFeedback: string | null;
|
|
||||||
quickSoundPresets: SoundPreset[];
|
|
||||||
selectedPresetId: string;
|
|
||||||
onClosePopover: () => void;
|
|
||||||
onOpenInbox: () => void;
|
|
||||||
onOpenControlCenter: () => void;
|
|
||||||
onToggleNotes: () => void;
|
|
||||||
onToggleSound: () => void;
|
|
||||||
onNoteDraftChange: (value: string) => void;
|
|
||||||
onNoteSubmit: () => void;
|
|
||||||
onToggleMute: () => void;
|
|
||||||
onVolumeChange: (nextVolume: number) => void;
|
|
||||||
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
|
|
||||||
onSelectPreset: (presetId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FocusModeAnchors = ({
|
|
||||||
isFocusMode,
|
|
||||||
isIdle,
|
|
||||||
openPopover,
|
|
||||||
thoughtCount,
|
|
||||||
noteDraft,
|
|
||||||
selectedSoundLabel,
|
|
||||||
isSoundMuted,
|
|
||||||
soundVolume,
|
|
||||||
volumeFeedback,
|
|
||||||
quickSoundPresets,
|
|
||||||
selectedPresetId,
|
|
||||||
onClosePopover,
|
|
||||||
onOpenInbox,
|
|
||||||
onOpenControlCenter,
|
|
||||||
onToggleNotes,
|
|
||||||
onToggleSound,
|
|
||||||
onNoteDraftChange,
|
|
||||||
onNoteSubmit,
|
|
||||||
onToggleMute,
|
|
||||||
onVolumeChange,
|
|
||||||
onVolumeKeyDown,
|
|
||||||
onSelectPreset,
|
|
||||||
}: FocusModeAnchorsProps) => {
|
|
||||||
if (!isFocusMode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{openPopover ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={copy.space.toolsDock.popoverCloseAria}
|
|
||||||
onClick={onClosePopover}
|
|
||||||
className="fixed inset-0 z-30 cursor-default"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FocusRightRail
|
|
||||||
isIdle={isIdle}
|
|
||||||
thoughtCount={thoughtCount}
|
|
||||||
openPopover={openPopover}
|
|
||||||
noteDraft={noteDraft}
|
|
||||||
selectedSoundLabel={selectedSoundLabel}
|
|
||||||
isSoundMuted={isSoundMuted}
|
|
||||||
soundVolume={soundVolume}
|
|
||||||
volumeFeedback={volumeFeedback}
|
|
||||||
quickSoundPresets={quickSoundPresets}
|
|
||||||
selectedPresetId={selectedPresetId}
|
|
||||||
onOpenInbox={onOpenInbox}
|
|
||||||
onOpenControlCenter={onOpenControlCenter}
|
|
||||||
onToggleNotes={onToggleNotes}
|
|
||||||
onToggleSound={onToggleSound}
|
|
||||||
onNoteDraftChange={onNoteDraftChange}
|
|
||||||
onNoteSubmit={onNoteSubmit}
|
|
||||||
onToggleMute={onToggleMute}
|
|
||||||
onVolumeChange={onVolumeChange}
|
|
||||||
onVolumeKeyDown={onVolumeKeyDown}
|
|
||||||
onSelectPreset={onSelectPreset}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
||||||
import type { SoundPreset } from '@/entities/session';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import type { SpaceAnchorPopoverId } from '../model/types';
|
|
||||||
import { formatThoughtCount, RAIL_ICON, ANCHOR_ICON } from './constants';
|
|
||||||
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
|
|
||||||
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
|
|
||||||
|
|
||||||
interface FocusRightRailProps {
|
|
||||||
isIdle: boolean;
|
|
||||||
thoughtCount: number;
|
|
||||||
openPopover: SpaceAnchorPopoverId | null;
|
|
||||||
noteDraft: string;
|
|
||||||
selectedSoundLabel: string;
|
|
||||||
isSoundMuted: boolean;
|
|
||||||
soundVolume: number;
|
|
||||||
volumeFeedback: string | null;
|
|
||||||
quickSoundPresets: SoundPreset[];
|
|
||||||
selectedPresetId: string;
|
|
||||||
onOpenInbox: () => void;
|
|
||||||
onOpenControlCenter: () => void;
|
|
||||||
onToggleNotes: () => void;
|
|
||||||
onToggleSound: () => void;
|
|
||||||
onNoteDraftChange: (value: string) => void;
|
|
||||||
onNoteSubmit: () => void;
|
|
||||||
onToggleMute: () => void;
|
|
||||||
onVolumeChange: (nextVolume: number) => void;
|
|
||||||
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
|
|
||||||
onSelectPreset: (presetId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FocusRightRail = ({
|
|
||||||
isIdle,
|
|
||||||
thoughtCount,
|
|
||||||
openPopover,
|
|
||||||
noteDraft,
|
|
||||||
selectedSoundLabel,
|
|
||||||
isSoundMuted,
|
|
||||||
soundVolume,
|
|
||||||
volumeFeedback,
|
|
||||||
quickSoundPresets,
|
|
||||||
selectedPresetId,
|
|
||||||
onOpenInbox,
|
|
||||||
onOpenControlCenter,
|
|
||||||
onToggleNotes,
|
|
||||||
onToggleSound,
|
|
||||||
onNoteDraftChange,
|
|
||||||
onNoteSubmit,
|
|
||||||
onToggleMute,
|
|
||||||
onVolumeChange,
|
|
||||||
onVolumeKeyDown,
|
|
||||||
onSelectPreset,
|
|
||||||
}: FocusRightRailProps) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed z-30 transition-all duration-500 right-[calc(env(safe-area-inset-right,0px)+1.5rem)] top-1/2 -translate-y-1/2',
|
|
||||||
isIdle ? 'opacity-0 translate-x-4 pointer-events-none' : 'opacity-100 translate-x-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="relative flex flex-col items-center gap-3">
|
|
||||||
|
|
||||||
{/* Thought Orb (Brain Dump) */}
|
|
||||||
<div className="relative group mb-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={copy.space.toolsDock.notesButton}
|
|
||||||
onClick={onToggleNotes}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-12 w-12 items-center justify-center rounded-full transition-all duration-500",
|
|
||||||
openPopover === 'notes'
|
|
||||||
? "bg-white text-black shadow-[0_0_40px_rgba(255,255,255,0.4)] scale-110"
|
|
||||||
: "bg-gradient-to-tr from-white/10 to-white/5 border border-white/10 text-white/90 hover:bg-white/20 hover:scale-105 hover:shadow-[0_0_20px_rgba(255,255,255,0.15)] backdrop-blur-md"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn("absolute inset-0 rounded-full bg-white/20 blur-md transition-opacity duration-500", openPopover === 'notes' ? "opacity-100" : "opacity-0 group-hover:opacity-50")} />
|
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5 relative z-10" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<span className="bg-white/10 backdrop-blur-md border border-white/10 text-white text-[10px] uppercase tracking-widest px-3 py-1.5 rounded-full whitespace-nowrap shadow-xl">
|
|
||||||
Brain Dump
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{openPopover === 'notes' ? (
|
|
||||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-6 w-[20rem]">
|
|
||||||
<div className="overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.06)_0%,rgba(255,255,255,0.01)_100%)] p-2 backdrop-blur-3xl shadow-2xl">
|
|
||||||
<QuickNotesPopover
|
|
||||||
noteDraft={noteDraft}
|
|
||||||
onDraftChange={onNoteDraftChange}
|
|
||||||
onDraftEnter={onNoteSubmit}
|
|
||||||
onSubmit={onNoteSubmit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Standard Tools */}
|
|
||||||
<div className="relative flex flex-col gap-2 rounded-full border border-white/10 bg-black/20 p-2.5 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.2)]">
|
|
||||||
|
|
||||||
{/* Sound Toggle */}
|
|
||||||
<div className="relative group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="사운드"
|
|
||||||
onClick={onToggleSound}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
|
|
||||||
openPopover === 'sound' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{ANCHOR_ICON.sound}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
|
|
||||||
사운드
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{openPopover === 'sound' ? (
|
|
||||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
|
|
||||||
<QuickSoundPopover
|
|
||||||
selectedSoundLabel={selectedSoundLabel}
|
|
||||||
isSoundMuted={isSoundMuted}
|
|
||||||
soundVolume={soundVolume}
|
|
||||||
volumeFeedback={volumeFeedback}
|
|
||||||
quickSoundPresets={quickSoundPresets}
|
|
||||||
selectedPresetId={selectedPresetId}
|
|
||||||
onToggleMute={onToggleMute}
|
|
||||||
onVolumeChange={onVolumeChange}
|
|
||||||
onVolumeKeyDown={onVolumeKeyDown}
|
|
||||||
onSelectPreset={onSelectPreset}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-6 h-px bg-white/10 mx-auto my-1" />
|
|
||||||
|
|
||||||
{/* Inbox Button */}
|
|
||||||
<div className="relative group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={copy.space.inbox.openInboxAriaLabel}
|
|
||||||
onClick={onOpenInbox}
|
|
||||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
{RAIL_ICON.inbox}
|
|
||||||
{thoughtCount > 0 ? (
|
|
||||||
<span className="absolute 0 top-0 right-0 inline-flex min-w-[1rem] items-center justify-center rounded-full bg-brand-primary px-1 py-0.5 text-[9px] font-bold text-white shadow-sm ring-2 ring-black/20">
|
|
||||||
{formatThoughtCount(thoughtCount)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
|
|
||||||
{copy.space.inbox.openInboxTitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Control Center Button */}
|
|
||||||
<div className="relative group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={copy.space.rightRail.openQuickControlsAriaLabel}
|
|
||||||
onClick={onOpenControlCenter}
|
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
{RAIL_ICON.controlCenter}
|
|
||||||
</button>
|
|
||||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
|
|
||||||
{copy.space.rightRail.openQuickControlsTitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import type { SceneAssetMap } from '@/entities/media';
|
|
||||||
import type { SceneTheme } from '@/entities/scene';
|
|
||||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import { ExitHoldButton } from '@/features/exit-hold';
|
|
||||||
import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
|
|
||||||
import { PlanPill } from '@/features/plan-pill';
|
|
||||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet';
|
|
||||||
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
|
|
||||||
import { getQuickSoundPresets } from '../model/getQuickSoundPresets';
|
|
||||||
import { UTILITY_PANEL_TITLE } from './constants';
|
|
||||||
import { FocusModeAnchors } from './FocusModeAnchors';
|
|
||||||
import { InboxToolPanel } from './panels/InboxToolPanel';
|
|
||||||
import { useSpaceToolsDockState } from '../model/useSpaceToolsDockState';
|
|
||||||
import { useSpaceToolsDockHandlers } from '../model/useSpaceToolsDockHandlers';
|
|
||||||
|
|
||||||
interface SpaceToolsDockWidgetProps {
|
|
||||||
isFocusMode: boolean;
|
|
||||||
scenes: SceneTheme[];
|
|
||||||
sceneAssetMap?: SceneAssetMap;
|
|
||||||
selectedSceneId: string;
|
|
||||||
selectedTimerLabel: string;
|
|
||||||
timerPresets: TimerPreset[];
|
|
||||||
selectedPresetId: string;
|
|
||||||
soundVolume: number;
|
|
||||||
onSetSoundVolume: (volume: number) => void;
|
|
||||||
isSoundMuted: boolean;
|
|
||||||
onSetSoundMuted: (nextMuted: boolean) => void;
|
|
||||||
thoughts: RecentThought[];
|
|
||||||
thoughtCount: number;
|
|
||||||
sceneRecommendedSoundLabel: string;
|
|
||||||
sceneRecommendedTimerLabel: string;
|
|
||||||
onSceneSelect: (sceneId: string) => void;
|
|
||||||
onTimerSelect: (timerLabel: string) => void;
|
|
||||||
onQuickSoundSelect: (presetId: string) => void;
|
|
||||||
onCaptureThought: (note: string) => RecentThought | null;
|
|
||||||
onDeleteThought: (thoughtId: string) => RecentThought | null;
|
|
||||||
onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
|
|
||||||
onRestoreThought: (thought: RecentThought) => void;
|
|
||||||
onRestoreThoughts: (thoughts: RecentThought[]) => void;
|
|
||||||
onClearInbox: () => RecentThought[];
|
|
||||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
|
||||||
onExitRequested: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SpaceToolsDockWidget = ({
|
|
||||||
isFocusMode,
|
|
||||||
scenes,
|
|
||||||
sceneAssetMap,
|
|
||||||
selectedSceneId,
|
|
||||||
selectedTimerLabel,
|
|
||||||
timerPresets,
|
|
||||||
selectedPresetId,
|
|
||||||
soundVolume,
|
|
||||||
onSetSoundVolume,
|
|
||||||
isSoundMuted,
|
|
||||||
onSetSoundMuted,
|
|
||||||
thoughts,
|
|
||||||
thoughtCount,
|
|
||||||
sceneRecommendedSoundLabel,
|
|
||||||
sceneRecommendedTimerLabel,
|
|
||||||
onSceneSelect,
|
|
||||||
onTimerSelect,
|
|
||||||
onQuickSoundSelect,
|
|
||||||
onCaptureThought,
|
|
||||||
onDeleteThought,
|
|
||||||
onSetThoughtCompleted,
|
|
||||||
onRestoreThought,
|
|
||||||
onRestoreThoughts,
|
|
||||||
onClearInbox,
|
|
||||||
onStatusMessage,
|
|
||||||
onExitRequested,
|
|
||||||
}: SpaceToolsDockWidgetProps) => {
|
|
||||||
const { controlCenter } = copy.space;
|
|
||||||
|
|
||||||
const {
|
|
||||||
openPopover,
|
|
||||||
setOpenPopover,
|
|
||||||
utilityPanel,
|
|
||||||
setUtilityPanel,
|
|
||||||
autoHideControls,
|
|
||||||
setAutoHideControls,
|
|
||||||
isIdle,
|
|
||||||
setIdle,
|
|
||||||
volumeFeedback,
|
|
||||||
showVolumeFeedback,
|
|
||||||
} = useSpaceToolsDockState({ isFocusMode });
|
|
||||||
|
|
||||||
const {
|
|
||||||
noteDraft,
|
|
||||||
setNoteDraft,
|
|
||||||
plan,
|
|
||||||
openUtilityPanel,
|
|
||||||
handleNoteSubmit,
|
|
||||||
handleInboxComplete,
|
|
||||||
handleInboxDelete,
|
|
||||||
handleInboxClear,
|
|
||||||
handlePlanPillClick,
|
|
||||||
handleLockedClick,
|
|
||||||
handleSelectProFeature,
|
|
||||||
handleStartPro,
|
|
||||||
handleVolumeChange,
|
|
||||||
handleVolumeKeyDown,
|
|
||||||
} = useSpaceToolsDockHandlers({
|
|
||||||
setIdle,
|
|
||||||
setOpenPopover,
|
|
||||||
setUtilityPanel,
|
|
||||||
onCaptureThought,
|
|
||||||
onDeleteThought,
|
|
||||||
onSetThoughtCompleted,
|
|
||||||
onRestoreThought,
|
|
||||||
onRestoreThoughts,
|
|
||||||
onClearInbox,
|
|
||||||
onStatusMessage,
|
|
||||||
onSetSoundVolume,
|
|
||||||
onSetSoundMuted,
|
|
||||||
soundVolume,
|
|
||||||
isSoundMuted,
|
|
||||||
showVolumeFeedback,
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedSoundLabel = useMemo(() => {
|
|
||||||
return (
|
|
||||||
SOUND_PRESETS.find((preset) => preset.id === selectedPresetId)?.label ?? SOUND_PRESETS[0]?.label ?? copy.common.default
|
|
||||||
);
|
|
||||||
}, [selectedPresetId]);
|
|
||||||
|
|
||||||
const quickSoundPresets = useMemo(() => {
|
|
||||||
return getQuickSoundPresets(SOUND_PRESETS);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!openPopover) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setOpenPopover(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleEscape);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleEscape);
|
|
||||||
};
|
|
||||||
}, [openPopover, setOpenPopover]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed z-30 transition-all duration-300 left-[calc(env(safe-area-inset-left,0px)+2rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)] group flex items-center justify-start',
|
|
||||||
isFocusMode ? (isIdle ? 'opacity-0 -translate-x-4 pointer-events-none' : 'opacity-80 hover:opacity-100') : 'opacity-92',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="relative flex items-center justify-start overflow-hidden rounded-full border border-white/10 bg-black/20 p-1.5 backdrop-blur-md transition-all duration-300 ease-out w-10 hover:w-[130px] hover:bg-black/40">
|
|
||||||
<div className="flex shrink-0 h-7 w-7 items-center justify-center text-white/70">
|
|
||||||
<span aria-hidden className="text-[14px]">⎋</span>
|
|
||||||
</div>
|
|
||||||
<div className="absolute left-10 whitespace-nowrap opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
|
||||||
<ExitHoldButton
|
|
||||||
variant="bar"
|
|
||||||
onConfirm={onExitRequested}
|
|
||||||
className="bg-transparent text-white/90 hover:bg-white/10 hover:text-white px-2 py-1 h-7"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FocusModeAnchors
|
|
||||||
isFocusMode={isFocusMode}
|
|
||||||
isIdle={isIdle}
|
|
||||||
openPopover={openPopover}
|
|
||||||
thoughtCount={thoughtCount}
|
|
||||||
noteDraft={noteDraft}
|
|
||||||
selectedSoundLabel={selectedSoundLabel}
|
|
||||||
isSoundMuted={isSoundMuted}
|
|
||||||
soundVolume={soundVolume}
|
|
||||||
volumeFeedback={volumeFeedback}
|
|
||||||
quickSoundPresets={quickSoundPresets}
|
|
||||||
selectedPresetId={selectedPresetId}
|
|
||||||
onClosePopover={() => setOpenPopover(null)}
|
|
||||||
onOpenInbox={() => openUtilityPanel('inbox')}
|
|
||||||
onOpenControlCenter={() => openUtilityPanel('control-center')}
|
|
||||||
onToggleNotes={() => {
|
|
||||||
setIdle(false);
|
|
||||||
setOpenPopover((current) => (current === 'notes' ? null : 'notes'));
|
|
||||||
}}
|
|
||||||
onToggleSound={() => {
|
|
||||||
setIdle(false);
|
|
||||||
setOpenPopover((current) => (current === 'sound' ? null : 'sound'));
|
|
||||||
}}
|
|
||||||
onNoteDraftChange={setNoteDraft}
|
|
||||||
onNoteSubmit={handleNoteSubmit}
|
|
||||||
onToggleMute={() => {
|
|
||||||
const nextMuted = !isSoundMuted;
|
|
||||||
onSetSoundMuted(nextMuted);
|
|
||||||
showVolumeFeedback(nextMuted ? 0 : soundVolume);
|
|
||||||
}}
|
|
||||||
onVolumeChange={handleVolumeChange}
|
|
||||||
onVolumeKeyDown={handleVolumeKeyDown}
|
|
||||||
onSelectPreset={(presetId) => {
|
|
||||||
onQuickSoundSelect(presetId);
|
|
||||||
setOpenPopover(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SpaceSideSheet
|
|
||||||
open={isFocusMode && utilityPanel !== null}
|
|
||||||
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
|
|
||||||
subtitle={utilityPanel === 'control-center' ? controlCenter.sideSheetSubtitle : undefined}
|
|
||||||
headerAction={
|
|
||||||
utilityPanel === 'control-center' ? (
|
|
||||||
<PlanPill plan={plan} onClick={handlePlanPillClick} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
widthClassName={utilityPanel === 'control-center' ? 'w-[min(408px,94vw)]' : undefined}
|
|
||||||
overlayClassName={utilityPanel === 'control-center' ? 'bg-slate-950/6 backdrop-blur-none' : undefined}
|
|
||||||
onClose={() => setUtilityPanel(null)}
|
|
||||||
>
|
|
||||||
{utilityPanel === 'control-center' ? (
|
|
||||||
<ControlCenterSheetWidget
|
|
||||||
plan={plan}
|
|
||||||
scenes={scenes}
|
|
||||||
sceneAssetMap={sceneAssetMap}
|
|
||||||
selectedSceneId={selectedSceneId}
|
|
||||||
selectedTimerLabel={selectedTimerLabel}
|
|
||||||
selectedSoundPresetId={selectedPresetId}
|
|
||||||
sceneRecommendedSoundLabel={sceneRecommendedSoundLabel}
|
|
||||||
sceneRecommendedTimerLabel={sceneRecommendedTimerLabel}
|
|
||||||
timerPresets={timerPresets}
|
|
||||||
autoHideControls={autoHideControls}
|
|
||||||
onAutoHideControlsChange={setAutoHideControls}
|
|
||||||
onSelectScene={(sceneId) => {
|
|
||||||
onSceneSelect(sceneId);
|
|
||||||
}}
|
|
||||||
onSelectTimer={(label) => {
|
|
||||||
onTimerSelect(label);
|
|
||||||
}}
|
|
||||||
onSelectSound={onQuickSoundSelect}
|
|
||||||
onSelectProFeature={handleSelectProFeature}
|
|
||||||
onLockedClick={handleLockedClick}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{utilityPanel === 'inbox' ? (
|
|
||||||
<InboxToolPanel
|
|
||||||
thoughts={thoughts}
|
|
||||||
onCompleteThought={handleInboxComplete}
|
|
||||||
onDeleteThought={handleInboxDelete}
|
|
||||||
onClear={handleInboxClear}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{utilityPanel === 'paywall' ? (
|
|
||||||
<PaywallSheetContent
|
|
||||||
onStartPro={handleStartPro}
|
|
||||||
onClose={() => setUtilityPanel(null)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{utilityPanel === 'manage-plan' ? (
|
|
||||||
<ManagePlanSheetContent
|
|
||||||
onClose={() => setUtilityPanel(null)}
|
|
||||||
onManage={() => onStatusMessage({ message: copy.space.toolsDock.manageSubscriptionMock })}
|
|
||||||
onRestore={() => onStatusMessage({ message: copy.space.toolsDock.restorePurchaseMock })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SpaceSideSheet>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import type { SpaceUtilityPanelId } from '../model/types';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
|
|
||||||
export const ANCHOR_ICON = {
|
|
||||||
sound: (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className="h-4 w-4"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M4 13V11a2 2 0 0 1 2-2h2l3-3h2v12h-2l-3-3H6a2 2 0 0 1-2-2Z" />
|
|
||||||
<path d="M16 9a4 4 0 0 1 0 6" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
notes: (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className="h-4 w-4"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M6 4h9l3 3v13H6z" />
|
|
||||||
<path d="M15 4v4h4" />
|
|
||||||
<path d="M9 12h6M9 16h4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RAIL_ICON = {
|
|
||||||
controlCenter: (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className="h-4 w-4"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
||||||
<circle cx="8" cy="7" r="1.2" fill="currentColor" stroke="none" />
|
|
||||||
<circle cx="16" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
|
||||||
<circle cx="11" cy="17" r="1.2" fill="currentColor" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
inbox: (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className="h-4 w-4"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="4" y="6" width="16" height="12" rx="2.5" />
|
|
||||||
<path d="m5 8 7 5 7-5" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UTILITY_PANEL_TITLE: Record<SpaceUtilityPanelId, string> = {
|
|
||||||
'control-center': copy.space.toolsDock.utilityPanelTitle['control-center'],
|
|
||||||
inbox: copy.space.toolsDock.utilityPanelTitle.inbox,
|
|
||||||
paywall: copy.space.toolsDock.utilityPanelTitle.paywall,
|
|
||||||
'manage-plan': copy.space.toolsDock.utilityPanelTitle['manage-plan'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatThoughtCount = (count: number) => {
|
|
||||||
if (count < 1) {
|
|
||||||
return '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count > 9) {
|
|
||||||
return '9+';
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(count);
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { RecentThought } from '@/entities/session';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import { InboxList } from '@/features/inbox';
|
|
||||||
|
|
||||||
interface InboxToolPanelProps {
|
|
||||||
thoughts: RecentThought[];
|
|
||||||
onCompleteThought: (thought: RecentThought) => void;
|
|
||||||
onDeleteThought: (thought: RecentThought) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InboxToolPanel = ({
|
|
||||||
thoughts,
|
|
||||||
onCompleteThought,
|
|
||||||
onDeleteThought,
|
|
||||||
onClear,
|
|
||||||
}: InboxToolPanelProps) => {
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative space-y-3.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-xs text-white/58">{copy.space.inbox.readOnly}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmOpen(true)}
|
|
||||||
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
|
|
||||||
>
|
|
||||||
{copy.space.inbox.clearAll}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<InboxList
|
|
||||||
thoughts={thoughts}
|
|
||||||
onCompleteThought={onCompleteThought}
|
|
||||||
onDeleteThought={onDeleteThought}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{confirmOpen ? (
|
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-2xl bg-slate-950/70 px-3 backdrop-blur-sm">
|
|
||||||
<div className="w-full max-w-[272px] rounded-2xl border border-white/14 bg-slate-900/92 p-3.5 shadow-xl shadow-slate-950/45">
|
|
||||||
<p className="text-sm font-medium text-white/92">{copy.space.inbox.clearConfirmTitle}</p>
|
|
||||||
<p className="mt-1 text-[11px] text-white/60">{copy.space.inbox.clearConfirmDescription}</p>
|
|
||||||
<div className="mt-3 flex justify-end gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmOpen(false)}
|
|
||||||
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
|
|
||||||
>
|
|
||||||
{copy.common.cancel}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onClear();
|
|
||||||
setConfirmOpen(false);
|
|
||||||
}}
|
|
||||||
className="rounded-full border border-rose-200/34 bg-rose-200/16 px-2.5 py-1 text-[11px] text-rose-100/92 transition-colors hover:bg-rose-200/24"
|
|
||||||
>
|
|
||||||
{copy.space.inbox.clearButton}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { SceneTheme } from '@/entities/scene';
|
|
||||||
import type { TimerPreset } from '@/entities/session';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
import { DEFAULT_PRESET_OPTIONS } from '@/shared/config/settingsOptions';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
|
|
||||||
interface SettingsToolPanelProps {
|
|
||||||
scenes: SceneTheme[];
|
|
||||||
selectedSceneId: string;
|
|
||||||
selectedTimerLabel: string;
|
|
||||||
timerPresets: TimerPreset[];
|
|
||||||
onSelectScene: (sceneId: string) => void;
|
|
||||||
onSelectTimer: (timerLabel: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsToolPanel = ({
|
|
||||||
scenes,
|
|
||||||
selectedSceneId,
|
|
||||||
selectedTimerLabel,
|
|
||||||
timerPresets,
|
|
||||||
onSelectScene,
|
|
||||||
onSelectTimer,
|
|
||||||
}: SettingsToolPanelProps) => {
|
|
||||||
const { settingsPanel } = copy.space;
|
|
||||||
const [reduceMotion, setReduceMotion] = useState(false);
|
|
||||||
const [defaultPresetId, setDefaultPresetId] = useState<
|
|
||||||
(typeof DEFAULT_PRESET_OPTIONS)[number]['id']
|
|
||||||
>(DEFAULT_PRESET_OPTIONS[0].id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">{settingsPanel.reduceMotion}</p>
|
|
||||||
<p className="mt-1 text-xs text-white/58">{settingsPanel.reduceMotionDescription}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={reduceMotion}
|
|
||||||
onClick={() => setReduceMotion((current) => !current)}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex w-14 items-center rounded-full border px-1 py-1 transition-colors',
|
|
||||||
reduceMotion
|
|
||||||
? 'border-sky-200/44 bg-sky-200/24'
|
|
||||||
: 'border-white/24 bg-white/9',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'h-4.5 w-4.5 rounded-full bg-white transition-transform duration-200 motion-reduce:transition-none',
|
|
||||||
reduceMotion ? 'translate-x-7' : 'translate-x-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
|
||||||
<p className="text-sm font-medium text-white">{settingsPanel.background}</p>
|
|
||||||
<p className="mt-1 text-xs text-white/58">{settingsPanel.backgroundDescription}</p>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{scenes.slice(0, 4).map((scene) => {
|
|
||||||
const selected = scene.id === selectedSceneId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={scene.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectScene(scene.id)}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full border px-3 py-1.5 text-xs transition-colors',
|
|
||||||
selected
|
|
||||||
? 'border-sky-200/44 bg-sky-200/20 text-sky-100'
|
|
||||||
: 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{scene.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
|
||||||
<p className="text-sm font-medium text-white">{settingsPanel.timerPreset}</p>
|
|
||||||
<p className="mt-1 text-xs text-white/58">{settingsPanel.timerPresetDescription}</p>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{timerPresets.slice(0, 3).map((preset) => {
|
|
||||||
const selected = preset.label === selectedTimerLabel;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectTimer(preset.label)}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full border px-3 py-1.5 text-xs transition-colors',
|
|
||||||
selected
|
|
||||||
? 'border-sky-200/44 bg-sky-200/20 text-sky-100'
|
|
||||||
: 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
|
||||||
<p className="text-sm font-medium text-white">{settingsPanel.defaultPreset}</p>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{DEFAULT_PRESET_OPTIONS.map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDefaultPresetId(preset.id)}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full border px-3 py-1.5 text-xs transition-colors',
|
|
||||||
defaultPresetId === preset.id
|
|
||||||
? 'border-sky-200/44 bg-sky-200/20 text-sky-100'
|
|
||||||
: 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
|
|
||||||
export const StatsToolPanel = () => {
|
|
||||||
const previewStats = [TODAY_STATS[0], TODAY_STATS[1], WEEKLY_STATS[0], WEEKLY_STATS[2]];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-xs text-white/58">{copy.space.statsPanel.description}</p>
|
|
||||||
|
|
||||||
<section className="grid gap-2.5 sm:grid-cols-2">
|
|
||||||
{previewStats.map((stat) => (
|
|
||||||
<article key={stat.id} className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
|
|
||||||
<p className="text-[11px] text-white/58">{stat.label}</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold text-white">{stat.value}</p>
|
|
||||||
<p className="mt-0.5 text-xs text-sky-100/78">{stat.delta}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-2xl border border-white/14 bg-white/6 p-3.5">
|
|
||||||
<div className="h-28 rounded-xl border border-dashed border-white/20 bg-[linear-gradient(180deg,rgba(148,163,184,0.14),rgba(148,163,184,0.02))]" />
|
|
||||||
<p className="mt-2 text-[11px] text-white/54">{copy.space.statsPanel.graphPlaceholder}</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { copy } from '@/shared/i18n';
|
|
||||||
|
|
||||||
interface QuickNotesPopoverProps {
|
|
||||||
noteDraft: string;
|
|
||||||
onDraftChange: (value: string) => void;
|
|
||||||
onDraftEnter: () => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QuickNotesPopover = ({
|
|
||||||
noteDraft,
|
|
||||||
onDraftChange,
|
|
||||||
onDraftEnter,
|
|
||||||
onSubmit,
|
|
||||||
}: QuickNotesPopoverProps) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
|
|
||||||
>
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickNotes.title}</p>
|
|
||||||
<div className="mt-4 flex flex-col gap-3">
|
|
||||||
<input
|
|
||||||
value={noteDraft}
|
|
||||||
onChange={(event) => onDraftChange(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key !== 'Enter') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
onDraftEnter();
|
|
||||||
}}
|
|
||||||
placeholder={copy.space.quickNotes.placeholder}
|
|
||||||
autoFocus
|
|
||||||
className="w-full border-b border-white/20 bg-transparent pb-2 text-sm text-white placeholder:text-white/30 transition-colors focus:border-white/60 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-[10px] text-white/40">{copy.space.quickNotes.hint}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={!noteDraft.trim()}
|
|
||||||
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium text-white transition-all hover:bg-white/20 active:scale-95 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
{copy.space.quickNotes.submit}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import type { SoundPreset } from '@/entities/session';
|
|
||||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
||||||
import { copy } from '@/shared/i18n';
|
|
||||||
|
|
||||||
interface QuickSoundPopoverProps {
|
|
||||||
selectedSoundLabel: string;
|
|
||||||
isSoundMuted: boolean;
|
|
||||||
soundVolume: number;
|
|
||||||
volumeFeedback: string | null;
|
|
||||||
quickSoundPresets: SoundPreset[];
|
|
||||||
selectedPresetId: string;
|
|
||||||
onToggleMute: () => void;
|
|
||||||
onVolumeChange: (nextVolume: number) => void;
|
|
||||||
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
|
|
||||||
onSelectPreset: (presetId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QuickSoundPopover = ({
|
|
||||||
selectedSoundLabel,
|
|
||||||
isSoundMuted,
|
|
||||||
soundVolume,
|
|
||||||
volumeFeedback,
|
|
||||||
quickSoundPresets,
|
|
||||||
selectedPresetId,
|
|
||||||
onToggleMute,
|
|
||||||
onVolumeChange,
|
|
||||||
onVolumeKeyDown,
|
|
||||||
onSelectPreset,
|
|
||||||
}: QuickSoundPopoverProps) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickSound.currentSound}</p>
|
|
||||||
<span className="text-[11px] font-medium text-white/90 bg-white/10 px-2 py-0.5 rounded-md">
|
|
||||||
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 truncate text-base font-medium text-white/90">{selectedSoundLabel}</p>
|
|
||||||
|
|
||||||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur-md">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isSoundMuted ? copy.space.quickSound.unmuteAriaLabel : copy.space.quickSound.muteAriaLabel}
|
|
||||||
onClick={onToggleMute}
|
|
||||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-[13px] text-white/80 transition-all hover:bg-white/20 active:scale-95"
|
|
||||||
>
|
|
||||||
{isSoundMuted ? '🔇' : '🔊'}
|
|
||||||
</button>
|
|
||||||
<div className="relative flex w-full items-center">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={soundVolume}
|
|
||||||
onChange={(event) => onVolumeChange(Number(event.target.value))}
|
|
||||||
onKeyDown={onVolumeKeyDown}
|
|
||||||
aria-label={copy.space.quickSound.volumeAriaLabel}
|
|
||||||
className="absolute z-10 w-full cursor-pointer appearance-none bg-transparent accent-white outline-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
|
|
||||||
/>
|
|
||||||
<div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-white/90 transition-all duration-150 ease-out"
|
|
||||||
style={{ width: `${soundVolume}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-widest text-white/40 mb-3">{copy.space.quickSound.quickSwitch}</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{quickSoundPresets.map((preset) => {
|
|
||||||
const selected = preset.id === selectedPresetId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectPreset(preset.id)}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full border px-3 py-1.5 text-[11px] font-medium transition-all active:scale-95',
|
|
||||||
selected
|
|
||||||
? 'border-white/40 bg-white/20 text-white shadow-sm'
|
|
||||||
: 'border-transparent bg-white/5 text-white/60 hover:bg-white/15 hover:text-white',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import type { FocusSession } from '@/features/focus-session';
|
|
||||||
|
|
||||||
const AWAY_HIDDEN_THRESHOLD_MS = 20_000;
|
|
||||||
const AWAY_SLEEP_GAP_THRESHOLD_MS = 90_000;
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
||||||
|
|
||||||
export type ReturnPromptMode = 'focus' | 'break';
|
|
||||||
|
|
||||||
interface UseAwayReturnRecoveryParams {
|
|
||||||
currentSession: FocusSession | null;
|
|
||||||
isBootstrapping: boolean;
|
|
||||||
syncCurrentSession: () => Promise<FocusSession | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseAwayReturnRecoveryResult {
|
|
||||||
returnPromptMode: ReturnPromptMode | null;
|
|
||||||
dismissReturnPrompt: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAwayReturnRecovery = ({
|
|
||||||
currentSession,
|
|
||||||
isBootstrapping,
|
|
||||||
syncCurrentSession,
|
|
||||||
}: UseAwayReturnRecoveryParams): UseAwayReturnRecoveryResult => {
|
|
||||||
const [returnPromptMode, setReturnPromptMode] = useState<ReturnPromptMode | null>(null);
|
|
||||||
const hiddenAtRef = useRef<number | null>(null);
|
|
||||||
const awayCandidateRef = useRef(false);
|
|
||||||
const heartbeatAtRef = useRef(Date.now());
|
|
||||||
const isHandlingReturnRef = useRef(false);
|
|
||||||
|
|
||||||
const isRunningFocusSession =
|
|
||||||
currentSession?.state === 'running' && currentSession.phase === 'focus';
|
|
||||||
|
|
||||||
const clearAwayCandidate = useCallback(() => {
|
|
||||||
hiddenAtRef.current = null;
|
|
||||||
awayCandidateRef.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dismissReturnPrompt = useCallback(() => {
|
|
||||||
setReturnPromptMode(null);
|
|
||||||
clearAwayCandidate();
|
|
||||||
}, [clearAwayCandidate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
heartbeatAtRef.current = Date.now();
|
|
||||||
}, [currentSession?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isRunningFocusSession) {
|
|
||||||
clearAwayCandidate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
heartbeatAtRef.current = Date.now();
|
|
||||||
}
|
|
||||||
}, HEARTBEAT_INTERVAL_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [clearAwayCandidate, isRunningFocusSession]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSession?.state !== 'running') {
|
|
||||||
if (returnPromptMode === 'focus') {
|
|
||||||
setReturnPromptMode(null);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSession.phase !== 'break' && returnPromptMode === 'break') {
|
|
||||||
setReturnPromptMode(null);
|
|
||||||
}
|
|
||||||
}, [currentSession?.phase, currentSession?.state, returnPromptMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isBootstrapping) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeHandleReturn = async () => {
|
|
||||||
if (isHandlingReturnRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hiddenDuration =
|
|
||||||
hiddenAtRef.current == null ? 0 : Date.now() - hiddenAtRef.current;
|
|
||||||
const sleepGap = Date.now() - heartbeatAtRef.current;
|
|
||||||
|
|
||||||
if (!awayCandidateRef.current) {
|
|
||||||
if (
|
|
||||||
isRunningFocusSession &&
|
|
||||||
document.visibilityState === 'visible' &&
|
|
||||||
sleepGap >= AWAY_SLEEP_GAP_THRESHOLD_MS
|
|
||||||
) {
|
|
||||||
awayCandidateRef.current = true;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hiddenAtRef.current != null && hiddenDuration < AWAY_HIDDEN_THRESHOLD_MS) {
|
|
||||||
clearAwayCandidate();
|
|
||||||
heartbeatAtRef.current = Date.now();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isHandlingReturnRef.current = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const syncedSession = await syncCurrentSession();
|
|
||||||
const resolvedSession = syncedSession ?? currentSession;
|
|
||||||
|
|
||||||
clearAwayCandidate();
|
|
||||||
heartbeatAtRef.current = Date.now();
|
|
||||||
|
|
||||||
if (!resolvedSession || resolvedSession.state !== 'running') {
|
|
||||||
setReturnPromptMode(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedSession.phase === 'focus') {
|
|
||||||
setReturnPromptMode('focus');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedSession.phase === 'break') {
|
|
||||||
setReturnPromptMode('break');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setReturnPromptMode(null);
|
|
||||||
} finally {
|
|
||||||
isHandlingReturnRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
if (isRunningFocusSession) {
|
|
||||||
hiddenAtRef.current = Date.now();
|
|
||||||
awayCandidateRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void maybeHandleReturn();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageHide = () => {
|
|
||||||
if (isRunningFocusSession) {
|
|
||||||
hiddenAtRef.current = Date.now();
|
|
||||||
awayCandidateRef.current = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWindowFocus = () => {
|
|
||||||
void maybeHandleReturn();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
window.addEventListener('pagehide', handlePageHide);
|
|
||||||
window.addEventListener('focus', handleWindowFocus);
|
|
||||||
window.addEventListener('pageshow', handleWindowFocus);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
window.removeEventListener('pagehide', handlePageHide);
|
|
||||||
window.removeEventListener('focus', handleWindowFocus);
|
|
||||||
window.removeEventListener('pageshow', handleWindowFocus);
|
|
||||||
};
|
|
||||||
}, [clearAwayCandidate, currentSession, isBootstrapping, isRunningFocusSession, syncCurrentSession]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
returnPromptMode,
|
|
||||||
dismissReturnPrompt,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user