chore(web): 사용하지 않는 legacy 위젯 정리

This commit is contained in:
2026-03-16 16:21:12 +09:00
parent ec941f3cde
commit f4910238a0
23 changed files with 0 additions and 2490 deletions

View File

@@ -1 +0,0 @@
export * from './ui/ControlCenterSheetWidget';

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1 +0,0 @@
export * from './ui/SpaceSideSheet';

View File

@@ -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>
</>
);
};

View File

@@ -1 +0,0 @@
export * from './ui/SpaceTimerHudWidget';

View File

@@ -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>
);
};

View File

@@ -1,2 +0,0 @@
export * from './model/types';
export * from './ui/SpaceToolsDockWidget';

View File

@@ -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);
};

View File

@@ -1,2 +0,0 @@
export type SpaceAnchorPopoverId = 'sound' | 'notes';
export type SpaceUtilityPanelId = 'control-center' | 'inbox' | 'manage-plan' | 'paywall';

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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}
/>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
};
};