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