refactor: FSD 구조 강화 및 파일 500줄 제한에 따른 대규모 리팩토링

- SpaceWorkspaceWidget 로직을 전용 훅 및 유틸리티로 분리 (900줄 -> 300줄)
- useSpaceWorkspaceSelection 훅을 기능별(영속성, 진단 등) 소형 훅으로 분리
- SpaceToolsDockWidget의 상태 및 핸들러 로직 추출
- 거대 i18n 번역 파일(ko.ts)을 도메인별 메시지 파일로 구조화
- AdminConsoleWidget 누락분 추가 및 미디어 엔티티 타입 오류 수정
This commit is contained in:
2026-03-11 15:08:36 +09:00
parent 7867bd39ca
commit 35f1dfb92d
36 changed files with 3238 additions and 2611 deletions

View File

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

View File

@@ -0,0 +1,35 @@
import type { AuthResponse } from '@/entities/auth';
const ADMIN_STORAGE_KEY = 'vr_admin_session';
export const readStoredSession = (): AuthResponse | null => {
if (typeof window === 'undefined') {
return null;
}
const rawValue = window.localStorage.getItem(ADMIN_STORAGE_KEY);
if (!rawValue) {
return null;
}
try {
return JSON.parse(rawValue) as AuthResponse;
} catch {
window.localStorage.removeItem(ADMIN_STORAGE_KEY);
return null;
}
};
export const storeSession = (session: AuthResponse | null) => {
if (typeof window === 'undefined') {
return;
}
if (!session) {
window.localStorage.removeItem(ADMIN_STORAGE_KEY);
return;
}
window.localStorage.setItem(ADMIN_STORAGE_KEY, JSON.stringify(session));
};

View File

@@ -0,0 +1,14 @@
import type { SceneMediaAssetUploadResponse, SoundMediaAssetUploadResponse } from '@/features/admin/api/adminApi';
export type AdminView = 'scene' | 'sound';
export type UploadResult =
| { type: 'scene'; payload: SceneMediaAssetUploadResponse }
| { type: 'sound'; payload: SoundMediaAssetUploadResponse };
export type NavItem = {
id: AdminView;
title: string;
subtitle: string;
section: string;
};

View File

@@ -0,0 +1,208 @@
'use client';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import type { AuthResponse } from '@/entities/auth';
import { adminApi } from '@/features/admin/api/adminApi';
import { copy } from '@/shared/i18n';
import { readStoredSession, storeSession } from './sessionStorage';
import type { AdminView, UploadResult } from './types';
const getViewMeta = (view: AdminView) => {
return copy.admin.views[view];
};
const formatResultSummary = (uploadResult: UploadResult | null) => {
if (!uploadResult) {
return copy.admin.inspector.noUploadSummary;
}
if (uploadResult.type === 'scene') {
return copy.admin.messages.sceneSummary(uploadResult.payload.sceneId, uploadResult.payload.assetVersion);
}
return copy.admin.messages.soundSummary(uploadResult.payload.presetId, uploadResult.payload.assetVersion);
};
export const useAdminConsole = () => {
const [session, setSession] = useState<AuthResponse | null>(null);
const [activeView, setActiveView] = useState<AdminView>('scene');
const [isDurationOverrideEnabled, setIsDurationOverrideEnabled] = useState(false);
const [loginId, setLoginId] = useState<string>(copy.admin.defaultLoginId);
const [password, setPassword] = useState<string>(copy.admin.defaultPassword);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginPending, setLoginPending] = useState(false);
const [scenePending, setScenePending] = useState(false);
const [soundPending, setSoundPending] = useState(false);
const [sceneMessage, setSceneMessage] = useState<string | null>(null);
const [soundMessage, setSoundMessage] = useState<string | null>(null);
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);
useEffect(() => {
setSession(readStoredSession());
}, []);
const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoginPending(true);
setLoginError(null);
try {
const response = await adminApi.login({
loginId: loginId.trim(),
password,
});
if (response.user?.grade !== 'ADMIN') {
throw new Error(copy.admin.messages.nonAdmin);
}
setSession(response);
storeSession(response);
} catch (error) {
setLoginError(error instanceof Error ? error.message : copy.admin.messages.loginFailed);
} finally {
setLoginPending(false);
}
};
const handleLogout = () => {
setSession(null);
setActiveView('scene');
setIsDurationOverrideEnabled(false);
setSceneMessage(null);
setSoundMessage(null);
setUploadResult(null);
storeSession(null);
};
const handleSceneUpload = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!session?.accessToken) {
setSceneMessage(copy.admin.messages.loginRequired);
return;
}
setScenePending(true);
setSceneMessage(null);
try {
const form = event.currentTarget;
const source = new FormData(form);
const sceneId = String(source.get('sceneId') ?? '').trim();
if (!sceneId) {
throw new Error(copy.admin.messages.sceneIdRequired);
}
const formData = new FormData();
const sourceImageFile = source.get('sourceImageFile');
if (sourceImageFile instanceof File && sourceImageFile.size > 0) {
formData.append('sourceImageFile', sourceImageFile);
}
const blurDataUrl = String(source.get('blurDataUrl') ?? '').trim();
if (blurDataUrl) {
formData.append('blurDataUrl', blurDataUrl);
}
const response = await adminApi.uploadScene(sceneId, formData, session.accessToken);
setUploadResult({ type: 'scene', payload: response });
setSceneMessage(copy.admin.messages.sceneUploadDone(sceneId));
form.reset();
} catch (error) {
setSceneMessage(error instanceof Error ? error.message : copy.admin.messages.sceneUploadFailed);
} finally {
setScenePending(false);
}
};
const handleSoundUpload = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!session?.accessToken) {
setSoundMessage(copy.admin.messages.loginRequired);
return;
}
setSoundPending(true);
setSoundMessage(null);
try {
const form = event.currentTarget;
const source = new FormData(form);
const presetId = String(source.get('presetId') ?? '').trim();
if (!presetId) {
throw new Error(copy.admin.messages.presetIdRequired);
}
const formData = new FormData();
for (const fieldName of ['loopFile', 'previewFile', 'fallbackLoopFile']) {
const file = source.get(fieldName);
if (file instanceof File && file.size > 0) {
formData.append(fieldName, file);
}
}
const defaultVolume = String(source.get('defaultVolume') ?? '').trim();
if (defaultVolume) {
formData.append('defaultVolume', defaultVolume);
}
if (isDurationOverrideEnabled) {
const durationSec = String(source.get('durationSec') ?? '').trim();
if (durationSec) {
formData.append('durationSec', durationSec);
}
}
const response = await adminApi.uploadSound(presetId, formData, session.accessToken);
setUploadResult({ type: 'sound', payload: response });
setSoundMessage(copy.admin.messages.soundUploadDone(presetId));
form.reset();
setIsDurationOverrideEnabled(false);
} catch (error) {
setSoundMessage(error instanceof Error ? error.message : copy.admin.messages.soundUploadFailed);
} finally {
setSoundPending(false);
}
};
const activeMeta = useMemo(() => getViewMeta(activeView), [activeView]);
const currentMessage = activeView === 'scene' ? sceneMessage : soundMessage;
const lastExtractedDurationSec =
uploadResult?.type === 'sound' ? uploadResult.payload.durationSec ?? null : null;
const resultSummary = useMemo(() => formatResultSummary(uploadResult), [uploadResult]);
return {
session,
activeView,
setActiveView,
activeMeta,
isDurationOverrideEnabled,
setIsDurationOverrideEnabled,
loginId,
setLoginId,
password,
setPassword,
loginError,
loginPending,
scenePending,
soundPending,
currentMessage,
uploadResult,
resultSummary,
lastExtractedDurationSec,
handleLogin,
handleLogout,
handleSceneUpload,
handleSoundUpload,
};
};

View File

@@ -0,0 +1,41 @@
'use client';
import { useAdminConsole } from '../model/useAdminConsole';
import { AdminLoginView } from './AdminLoginView';
export const AdminConsoleWidget = () => {
const {
session,
loginId,
password,
loginError,
loginPending,
setLoginId,
setPassword,
handleLogin,
} = useAdminConsole();
if (!session) {
return (
<AdminLoginView
loginId={loginId}
password={password}
loginError={loginError}
loginPending={loginPending}
onLoginIdChange={setLoginId}
onPasswordChange={setPassword}
onSubmit={handleLogin}
/>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#f3f4f8] text-slate-900">
<div className="text-center">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="mt-2 text-slate-500">Welcome, {session.user?.name}!</p>
<p className="mt-4 text-sm text-slate-400">Dashboard is under construction.</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
import { copy } from '@/shared/i18n';
import { Button } from '@/shared/ui';
import { fieldClassName } from './constants';
interface AdminLoginViewProps {
loginId: string;
password: string;
loginError: string | null;
loginPending: boolean;
onLoginIdChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: React.FormEventHandler<HTMLFormElement>;
}
export const AdminLoginView = ({
loginId,
password,
loginError,
loginPending,
onLoginIdChange,
onPasswordChange,
onSubmit,
}: AdminLoginViewProps) => {
return (
<main className="min-h-screen bg-[#f3f4f8] text-slate-900">
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="flex flex-col justify-between bg-[#171821] px-8 py-8 text-white">
<div>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-fuchsia-500 to-sky-500 text-lg font-bold">
V
</div>
<div>
<p className="text-xl font-semibold tracking-tight">{copy.appName}</p>
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">{copy.admin.consoleLabel}</p>
</div>
</div>
<div className="mt-12 space-y-8">
<div>
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
{copy.admin.mediaOperations}
</p>
<div className="space-y-2">
{copy.admin.navItems.map((item, index) => (
<div
key={item.id}
className={`flex items-center gap-3 rounded-xl px-4 py-3 ${
index === 0 ? 'bg-white/10 text-white' : 'text-slate-400'
}`}
>
<span className="h-2 w-2 rounded-full bg-sky-400" />
<div>
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-slate-500">{item.subtitle}</p>
</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 p-5">
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">{copy.admin.access}</p>
<p className="mt-3 text-2xl font-semibold">{copy.admin.accessTitle}</p>
<p className="mt-2 text-sm leading-6 text-slate-400">{copy.admin.accessDescription}</p>
</div>
</div>
</div>
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">
{copy.admin.localCredentialsHint}
</p>
</aside>
<section className="flex items-center justify-center px-6 py-10">
<div className="w-full max-w-md rounded-[28px] border border-slate-200 bg-white px-8 py-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]">
<div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-600">{copy.admin.signInEyebrow}</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
{copy.admin.loginTitle}
</h1>
<p className="mt-3 text-sm leading-6 text-slate-500">{copy.admin.loginDescription}</p>
</div>
<form className="space-y-4" onSubmit={onSubmit}>
<div>
<label htmlFor="admin-login-id" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.loginIdLabel}
</label>
<input
id="admin-login-id"
className={fieldClassName}
value={loginId}
onChange={(event) => onLoginIdChange(event.target.value)}
autoComplete="username"
placeholder={copy.admin.defaultLoginId}
/>
</div>
<div>
<label htmlFor="admin-login-password" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.loginPasswordLabel}
</label>
<input
id="admin-login-password"
type="password"
className={fieldClassName}
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
autoComplete="current-password"
placeholder={copy.admin.defaultPassword}
/>
</div>
{loginError ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{loginError}
</div>
) : null}
<Button type="submit" size="full" className="h-12 rounded-xl" disabled={loginPending}>
{loginPending ? copy.admin.loggingIn : copy.admin.openDashboard}
</Button>
</form>
</div>
</section>
</div>
</main>
);
};

View File

@@ -0,0 +1,13 @@
import { copy } from '@/shared/i18n';
import type { NavItem } from '../model/types';
export const navItems: NavItem[] = [...copy.admin.navItems];
export const fieldClassName =
'h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100';
export const fileClassName =
'block w-full rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-900 file:px-3 file:py-2 file:text-sm file:font-medium file:text-white hover:file:bg-slate-800';
export const textareaClassName =
'min-h-32 w-full rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100';

View File

@@ -0,0 +1,206 @@
'use client';
import { useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { RecentThought } from '@/entities/session';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
import type { PlanTier } from '@/entities/plan';
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] = useState<PlanTier>('normal');
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 === 'scene-packs'
? toolsDock.featureLabels.scenePacks
: featureId === 'sound-packs'
? toolsDock.featureLabels.soundPacks
: toolsDock.featureLabels.profiles;
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
}, [onStatusMessage, toolsDock.featureLabels]);
const handleStartPro = useCallback(() => {
setPlan('pro');
onStatusMessage({ message: toolsDock.purchaseMock });
openUtilityPanel('control-center');
}, [onStatusMessage, openUtilityPanel, 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

@@ -0,0 +1,143 @@
'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

@@ -0,0 +1,160 @@
'use client';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { SoundPreset } from '@/entities/session';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import type { SpaceAnchorPopoverId } from '../model/types';
import { ANCHOR_ICON, formatThoughtCount } from './constants';
import { FocusRightRail } from './FocusRightRail';
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
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;
}
const anchorContainerClassName =
'fixed z-30 transition-opacity bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]';
const anchorHaloClassName =
'pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]';
const anchorButtonClassName =
'inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100';
export const FocusModeAnchors = ({
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"
/>
) : null}
<FocusRightRail
isIdle={isIdle}
thoughtCount={thoughtCount}
onOpenInbox={onOpenInbox}
onOpenControlCenter={onOpenControlCenter}
/>
<div
className={cn(
anchorContainerClassName,
'left-[calc(env(safe-area-inset-left,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleNotes}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
<span>{copy.space.toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'notes' ? (
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
) : null}
</div>
</div>
<div
className={cn(
anchorContainerClassName,
'right-[calc(env(safe-area-inset-right,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleSound}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'sound' ? (
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
) : null}
</div>
</div>
</>
);
};

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useEffect, useMemo } from 'react';
import type { SceneAssetMap } from '@/entities/media';
import type { PlanTier } from '@/entities/plan';
import type { SceneTheme } from '@/entities/scene';
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
import { copy } from '@/shared/i18n';
@@ -12,13 +11,13 @@ 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 type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types';
import { getQuickSoundPresets } from '../model/getQuickSoundPresets';
import { ANCHOR_ICON, formatThoughtCount, UTILITY_PANEL_TITLE } from './constants';
import { FocusRightRail } from './FocusRightRail';
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
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[];
@@ -76,15 +75,53 @@ export const SpaceToolsDockWidget = ({
onStatusMessage,
onExitRequested,
}: SpaceToolsDockWidgetProps) => {
const { toolsDock, controlCenter } = copy.space;
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
const [autoHideControls, setAutoHideControls] = useState(true);
const [noteDraft, setNoteDraft] = useState('');
const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null);
const [plan, setPlan] = useState<PlanTier>('normal');
const [isIdle, setIdle] = useState(false);
const volumeFeedbackTimerRef = useRef<number | null>(null);
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 (
@@ -96,15 +133,6 @@ export const SpaceToolsDockWidget = ({
return getQuickSoundPresets(SOUND_PRESETS);
}, []);
useEffect(() => {
return () => {
if (volumeFeedbackTimerRef.current) {
window.clearTimeout(volumeFeedbackTimerRef.current);
volumeFeedbackTimerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!openPopover) {
return;
@@ -121,263 +149,10 @@ export const SpaceToolsDockWidget = ({
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [openPopover]);
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 openUtilityPanel = (panel: SpaceUtilityPanelId) => {
setIdle(false);
setOpenPopover(null);
setUtilityPanel(panel);
};
const handleNoteSubmit = () => {
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 });
},
},
});
};
const handleInboxComplete = (thought: RecentThought) => {
onSetThoughtCompleted(thought.id, !thought.isCompleted);
};
const handleInboxDelete = (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 });
},
},
});
};
const handleInboxClear = () => {
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 });
},
},
});
};
const handlePlanPillClick = () => {
if (plan === 'pro') {
openUtilityPanel('manage-plan');
return;
}
onStatusMessage({ message: toolsDock.normalPlanInfo });
};
const handleLockedClick = (source: string) => {
onStatusMessage({ message: toolsDock.proFeatureLocked(source) });
openUtilityPanel('paywall');
};
const handleSelectProFeature = (featureId: string) => {
const label =
featureId === 'scene-packs'
? toolsDock.featureLabels.scenePacks
: featureId === 'sound-packs'
? toolsDock.featureLabels.soundPacks
: toolsDock.featureLabels.profiles;
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
};
const handleStartPro = () => {
setPlan('pro');
onStatusMessage({ message: toolsDock.purchaseMock });
openUtilityPanel('control-center');
};
const showVolumeFeedback = (nextVolume: number) => {
setVolumeFeedback(`${nextVolume}%`);
if (volumeFeedbackTimerRef.current) {
window.clearTimeout(volumeFeedbackTimerRef.current);
}
volumeFeedbackTimerRef.current = window.setTimeout(() => {
setVolumeFeedback(null);
volumeFeedbackTimerRef.current = null;
}, 900);
};
const handleVolumeChange = (nextVolume: number) => {
const clamped = Math.min(100, Math.max(0, nextVolume));
onSetSoundVolume(clamped);
if (isSoundMuted && clamped > 0) {
onSetSoundMuted(false);
}
showVolumeFeedback(clamped);
};
const handleVolumeKeyDown = (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);
};
}, [openPopover, setOpenPopover]);
return (
<>
{isFocusMode && openPopover ? (
<button
type="button"
aria-label={toolsDock.popoverCloseAria}
onClick={() => setOpenPopover(null)}
className="fixed inset-0 z-30"
/>
) : null}
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-[calc(env(safe-area-inset-top,0px)+0.75rem)]',
@@ -390,99 +165,43 @@ export const SpaceToolsDockWidget = ({
/>
</div>
{isFocusMode ? (
<>
<FocusRightRail
isIdle={isIdle}
thoughtCount={thoughtCount}
onOpenInbox={() => openUtilityPanel('inbox')}
onOpenControlCenter={() => openUtilityPanel('control-center')}
/>
<div
className={cn(
'fixed z-30 transition-opacity left-[calc(env(safe-area-inset-left,0px)+0.75rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]"
/>
<button
type="button"
onClick={() => {
setIdle(false);
setOpenPopover((current) => (current === 'notes' ? null : 'notes'));
}}
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
<span>{toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'notes' ? (
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={setNoteDraft}
onDraftEnter={handleNoteSubmit}
onSubmit={handleNoteSubmit}
/>
) : null}
</div>
</div>
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]"
/>
<button
type="button"
onClick={() => {
setIdle(false);
setOpenPopover((current) => (current === 'sound' ? null : 'sound'));
}}
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'sound' ? (
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={() => {
const nextMuted = !isSoundMuted;
onSetSoundMuted(nextMuted);
showVolumeFeedback(nextMuted ? 0 : soundVolume);
}}
onVolumeChange={handleVolumeChange}
onVolumeKeyDown={handleVolumeKeyDown}
onSelectPreset={(presetId) => {
onQuickSoundSelect(presetId);
setOpenPopover(null);
}}
/>
) : null}
</div>
</div>
</>
) : null}
<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}
@@ -541,8 +260,8 @@ export const SpaceToolsDockWidget = ({
{utilityPanel === 'manage-plan' ? (
<ManagePlanSheetContent
onClose={() => setUtilityPanel(null)}
onManage={() => onStatusMessage({ message: toolsDock.manageSubscriptionMock })}
onRestore={() => onStatusMessage({ message: toolsDock.restorePurchaseMock })}
onManage={() => onStatusMessage({ message: copy.space.toolsDock.manageSubscriptionMock })}
onRestore={() => onStatusMessage({ message: copy.space.toolsDock.restorePurchaseMock })}
/>
) : null}
</SpaceSideSheet>

View File

@@ -0,0 +1,15 @@
export type WorkspaceMode = 'setup' | 'focus';
export type SessionEntryPoint = 'space-setup' | 'goal-complete' | 'resume-restore';
export type SelectionOverride = {
sound: boolean;
timer: boolean;
};
export interface StoredWorkspaceSelection {
sceneId?: string;
timerPresetId?: string;
soundPresetId?: string;
goal?: string;
override?: Partial<SelectionOverride>;
}

View File

@@ -0,0 +1,440 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SceneAssetMap } from '@/entities/media';
import {
getSceneById,
normalizeSceneId,
SCENE_THEMES,
type SceneTheme,
} from '@/entities/scene';
import {
SOUND_PRESETS,
type GoalChip,
} from '@/entities/session';
import type { FocusSession } from '@/features/focus-session';
import { preferencesApi } from '@/features/preferences/api/preferencesApi';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import type { SelectionOverride } from './types';
import {
readStoredWorkspaceSelection,
resolveTimerLabelFromPresetId,
resolveTimerPresetIdFromLabel,
} from './workspaceSelection';
import { useWorkspacePersistence } from './useWorkspacePersistence';
import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics';
interface UseSpaceWorkspaceSelectionParams {
initialSceneId: string;
initialGoal: string;
initialTimerLabel: string;
sceneQuery: string | null;
goalQuery: string;
soundQuery: string | null;
timerQuery: string | null;
hasQueryOverrides: boolean;
currentSession: FocusSession | null;
sceneAssetMap: SceneAssetMap;
selectedPresetId: string;
setSelectedPresetId: (presetId: string) => void;
shouldPlaySound: boolean;
unlockPlayback: (requestedUrl?: string | null) => Promise<boolean>;
resolveSoundPlaybackUrl: (presetId: string) => string | null;
pushStatusLine: (payload: HudStatusLinePayload) => void;
updateCurrentSelection: (payload: {
sceneId?: string;
soundPresetId?: string | null;
}) => Promise<FocusSession | null>;
mediaCatalogError: string | null;
usedFallbackManifest: boolean;
hasResolvedManifest: boolean;
}
const getVisibleSetupScenes = (selectedScene: SceneTheme) => {
const visibleScenes = SCENE_THEMES.slice(0, 6);
if (visibleScenes.some((scene) => scene.id === selectedScene.id)) {
return visibleScenes;
}
return [selectedScene, ...visibleScenes].slice(0, 6);
};
export const useSpaceWorkspaceSelection = ({
initialSceneId,
initialGoal,
initialTimerLabel,
sceneQuery,
goalQuery,
soundQuery,
timerQuery,
hasQueryOverrides,
currentSession,
sceneAssetMap,
selectedPresetId,
setSelectedPresetId,
shouldPlaySound,
unlockPlayback,
resolveSoundPlaybackUrl,
pushStatusLine,
updateCurrentSelection,
mediaCatalogError,
usedFallbackManifest,
hasResolvedManifest,
}: UseSpaceWorkspaceSelectionParams) => {
const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId);
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
const [goalInput, setGoalInput] = useState(initialGoal);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [resumeGoal, setResumeGoal] = useState('');
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [hasHydratedSelection, setHasHydratedSelection] = useState(false);
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
sound: false,
timer: false,
});
const didHydrateServerPreferencesRef = useRef(false);
const selectedScene = useMemo(() => {
return getSceneById(selectedSceneId) ?? SCENE_THEMES[0];
}, [selectedSceneId]);
const selectedSceneAsset = sceneAssetMap[selectedScene.id];
const setupScenes = useMemo(() => {
return getVisibleSetupScenes(selectedScene);
}, [selectedScene]);
const canStart = goalInput.trim().length > 0;
const applyRecommendedSelections = useCallback((
sceneId: string,
overrideState: SelectionOverride = selectionOverride,
) => {
const scene = getSceneById(sceneId);
if (!scene) {
return;
}
if (!overrideState.timer) {
const recommendedTimerLabel = resolveTimerLabelFromPresetId(scene.recommendedTimerPresetId);
if (recommendedTimerLabel) {
setSelectedTimerLabel(recommendedTimerLabel);
}
}
if (
!overrideState.sound &&
SOUND_PRESETS.some((preset) => preset.id === scene.recommendedSoundPresetId)
) {
setSelectedPresetId(scene.recommendedSoundPresetId);
}
}, [selectionOverride, setSelectedPresetId]);
const persistSpaceSelection = useCallback((selection: {
sceneId?: string;
soundPresetId?: string | null;
}) => {
const preferencePayload: {
defaultSceneId?: string | null;
defaultSoundPresetId?: string | null;
} = {};
const currentSessionPayload: {
sceneId?: string;
soundPresetId?: string | null;
} = {};
if (selection.sceneId !== undefined) {
preferencePayload.defaultSceneId = selection.sceneId;
currentSessionPayload.sceneId = selection.sceneId;
}
if (selection.soundPresetId !== undefined) {
preferencePayload.defaultSoundPresetId = selection.soundPresetId;
currentSessionPayload.soundPresetId = selection.soundPresetId;
}
void (async () => {
const [preferencesResult, sessionResult] = await Promise.allSettled([
preferencesApi.updateFocusPreferences(preferencePayload),
currentSession ? updateCurrentSelection(currentSessionPayload) : Promise.resolve(null),
]);
if (preferencesResult.status === 'rejected') {
pushStatusLine({
message: copy.space.workspace.selectionPreferenceSaveFailed,
});
}
if (
currentSession &&
(sessionResult.status === 'rejected' || sessionResult.value === null)
) {
pushStatusLine({
message: copy.space.workspace.selectionSessionSyncFailed,
});
}
})();
}, [currentSession, pushStatusLine, updateCurrentSelection]);
const handleSelectScene = useCallback((sceneId: string) => {
void (async () => {
const normalizedSceneId = normalizeSceneId(sceneId) ?? sceneId;
const recommendedScene = getSceneById(normalizedSceneId);
const nextSoundPresetId =
recommendedScene &&
!selectionOverride.sound &&
SOUND_PRESETS.some((preset) => preset.id === recommendedScene.recommendedSoundPresetId)
? recommendedScene.recommendedSoundPresetId
: selectedPresetId;
if (shouldPlaySound) {
await unlockPlayback(resolveSoundPlaybackUrl(nextSoundPresetId));
}
setSelectedSceneId(normalizedSceneId);
applyRecommendedSelections(normalizedSceneId);
persistSpaceSelection({
sceneId: normalizedSceneId,
soundPresetId: nextSoundPresetId,
});
})();
}, [
applyRecommendedSelections,
persistSpaceSelection,
resolveSoundPlaybackUrl,
selectedPresetId,
selectionOverride.sound,
shouldPlaySound,
unlockPlayback,
]);
const handleSelectTimer = useCallback((timerLabel: string, markOverride = false) => {
setSelectedTimerLabel(timerLabel);
if (!markOverride) {
return;
}
setSelectionOverride((current) => {
if (current.timer) {
return current;
}
return { ...current, timer: true };
});
}, []);
const handleSelectSound = useCallback((presetId: string, markOverride = false) => {
void (async () => {
if (shouldPlaySound) {
await unlockPlayback(resolveSoundPlaybackUrl(presetId));
}
setSelectedPresetId(presetId);
if (!markOverride) {
return;
}
setSelectionOverride((current) => {
if (current.sound) {
return current;
}
return { ...current, sound: true };
});
persistSpaceSelection({
soundPresetId: presetId,
});
})();
}, [
persistSpaceSelection,
resolveSoundPlaybackUrl,
setSelectedPresetId,
shouldPlaySound,
unlockPlayback,
]);
const handleGoalChipSelect = useCallback((chip: GoalChip) => {
setShowResumePrompt(false);
setSelectedGoalId(chip.id);
setGoalInput(chip.label);
}, []);
const handleGoalChange = useCallback((value: string) => {
if (showResumePrompt) {
setShowResumePrompt(false);
}
setGoalInput(value);
if (value.trim().length === 0) {
setSelectedGoalId(null);
}
}, [showResumePrompt]);
useEffect(() => {
const storedSelection = readStoredWorkspaceSelection();
const restoredSelectionOverride: SelectionOverride = {
sound: Boolean(storedSelection.override?.sound),
timer: Boolean(storedSelection.override?.timer),
};
const restoredSceneId =
!sceneQuery && storedSelection.sceneId && getSceneById(storedSelection.sceneId)
? normalizeSceneId(storedSelection.sceneId)
: null;
const restoredTimerLabel = !timerQuery
? resolveTimerLabelFromPresetId(storedSelection.timerPresetId)
: null;
const restoredSoundPresetId =
!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)
? storedSelection.soundPresetId
: null;
const restoredGoal = storedSelection.goal?.trim() ?? '';
const rafId = window.requestAnimationFrame(() => {
setSelectionOverride(restoredSelectionOverride);
if (restoredSceneId) {
setSelectedSceneId(restoredSceneId);
}
if (restoredTimerLabel) {
setSelectedTimerLabel(restoredTimerLabel);
}
if (restoredSoundPresetId) {
setSelectedPresetId(restoredSoundPresetId);
}
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) {
setResumeGoal(restoredGoal);
setShowResumePrompt(true);
}
setHasHydratedSelection(true);
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [goalQuery, hasQueryOverrides, sceneQuery, setSelectedPresetId, soundQuery, timerQuery]);
useEffect(() => {
if (!hasHydratedSelection || didHydrateServerPreferencesRef.current) {
return;
}
didHydrateServerPreferencesRef.current = true;
let cancelled = false;
void preferencesApi
.getFocusPreferences()
.then((preferences) => {
if (cancelled || currentSession || hasQueryOverrides) {
return;
}
const normalizedPreferredSceneId = normalizeSceneId(preferences.defaultSceneId);
const nextSceneId =
normalizedPreferredSceneId && getSceneById(normalizedPreferredSceneId)
? normalizedPreferredSceneId
: null;
const nextSoundPresetId =
preferences.defaultSoundPresetId &&
SOUND_PRESETS.some((preset) => preset.id === preferences.defaultSoundPresetId)
? preferences.defaultSoundPresetId
: null;
if (nextSceneId) {
setSelectedSceneId(nextSceneId);
}
if (nextSoundPresetId) {
setSelectedPresetId(nextSoundPresetId);
setSelectionOverride((current) => ({ ...current, sound: true }));
}
})
.catch(() => {
// Focus preference load failure should not block entering the space.
});
return () => {
cancelled = true;
};
}, [currentSession, hasHydratedSelection, hasQueryOverrides, setSelectedPresetId]);
useEffect(() => {
if (!currentSession) {
return;
}
const nextTimerLabel =
resolveTimerLabelFromPresetId(currentSession.timerPresetId) ?? selectedTimerLabel;
const nextSoundPresetId =
currentSession.soundPresetId &&
SOUND_PRESETS.some((preset) => preset.id === currentSession.soundPresetId)
? currentSession.soundPresetId
: selectedPresetId;
const rafId = window.requestAnimationFrame(() => {
setSelectedSceneId(normalizeSceneId(currentSession.sceneId) ?? currentSession.sceneId);
setSelectedTimerLabel(nextTimerLabel);
setSelectedPresetId(nextSoundPresetId);
setGoalInput(currentSession.goal);
setSelectedGoalId(null);
setShowResumePrompt(false);
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
useWorkspacePersistence({
hasHydratedSelection,
selectedScene,
selectedTimerPresetId: resolveTimerPresetIdFromLabel(selectedTimerLabel),
selectedPresetId,
goalInput,
showResumePrompt,
resumeGoal,
selectionOverride,
});
useWorkspaceMediaDiagnostics({
mediaCatalogError,
pushStatusLine,
selectedScene,
hasResolvedManifest,
usedFallbackManifest,
selectedSceneAsset,
});
return {
selectedSceneId,
selectedTimerLabel,
goalInput,
selectedGoalId,
resumeGoal,
showResumePrompt,
hasHydratedSelection,
selectionOverride,
selectedScene,
selectedSceneAsset,
setupScenes,
canStart,
setGoalInput,
setSelectedGoalId,
setShowResumePrompt,
setResumeGoal,
handleSelectScene,
handleSelectTimer,
handleSelectSound,
handleGoalChipSelect,
handleGoalChange,
};
};

View File

@@ -0,0 +1,316 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FocusSession } from '@/features/focus-session';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { resolveTimerPresetIdFromLabel } from './workspaceSelection';
import type { SessionEntryPoint, WorkspaceMode } from './types';
interface UseSpaceWorkspaceSessionControlsParams {
canStart: boolean;
currentSession: FocusSession | null;
goalInput: string;
selectedSceneId: string;
selectedTimerLabel: string;
selectedPresetId: string;
soundPlaybackError: string | null;
pushStatusLine: (payload: HudStatusLinePayload) => void;
unlockPlayback: (requestedUrl?: string | null) => Promise<boolean>;
resolveSoundPlaybackUrl: (presetId: string) => string | null;
startSession: (input: {
sceneId: string;
goal: string;
timerPresetId: string;
soundPresetId: string;
entryPoint: SessionEntryPoint;
}) => Promise<FocusSession | null>;
pauseSession: () => Promise<FocusSession | null>;
resumeSession: () => Promise<FocusSession | null>;
restartCurrentPhase: () => Promise<FocusSession | null>;
completeSession: (input: {
completionType: 'goal-complete';
completedGoal: string;
}) => Promise<FocusSession | null>;
abandonSession: () => Promise<boolean>;
setGoalInput: (value: string) => void;
setSelectedGoalId: (value: string | null) => void;
setShowResumePrompt: (value: boolean) => void;
}
export const useSpaceWorkspaceSessionControls = ({
canStart,
currentSession,
goalInput,
selectedSceneId,
selectedTimerLabel,
selectedPresetId,
soundPlaybackError,
pushStatusLine,
unlockPlayback,
resolveSoundPlaybackUrl,
startSession,
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
abandonSession,
setGoalInput,
setSelectedGoalId,
setShowResumePrompt,
}: UseSpaceWorkspaceSessionControlsParams) => {
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup');
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('paused');
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
useState<SessionEntryPoint>('space-setup');
const queuedFocusStatusMessageRef = useRef<string | null>(null);
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
const resolvedPlaybackState = currentSession?.state ?? previewPlaybackState;
const isFocusMode = workspaceMode === 'focus';
const canStartSession = canStart && (!currentSession || resolvedPlaybackState !== 'running');
const canPauseSession = Boolean(currentSession && resolvedPlaybackState === 'running');
const canRestartSession = Boolean(currentSession);
const openFocusMode = useCallback((
nextGoal: string,
entryPoint: SessionEntryPoint = 'space-setup',
) => {
const trimmedGoal = nextGoal.trim();
if (!trimmedGoal) {
return;
}
setShowResumePrompt(false);
setPendingSessionEntryPoint(entryPoint);
setPreviewPlaybackState('paused');
setWorkspaceMode('focus');
queuedFocusStatusMessageRef.current = copy.space.workspace.readyToStart;
}, [setShowResumePrompt]);
const startFocusFlow = useCallback(async () => {
const trimmedGoal = goalInput.trim();
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
if (!trimmedGoal || !timerPresetId) {
return;
}
const startedSession = await startSession({
sceneId: selectedSceneId,
goal: trimmedGoal,
timerPresetId,
soundPresetId: selectedPresetId,
entryPoint: pendingSessionEntryPoint,
});
if (startedSession) {
setPreviewPlaybackState('running');
return;
}
setPreviewPlaybackState('paused');
pushStatusLine({
message: copy.space.workspace.startFailed,
});
}, [
goalInput,
pendingSessionEntryPoint,
pushStatusLine,
selectedPresetId,
selectedSceneId,
selectedTimerLabel,
startSession,
]);
const handleSetupFocusOpen = useCallback(() => {
if (!canStart) {
return;
}
openFocusMode(goalInput, 'space-setup');
}, [canStart, goalInput, openFocusMode]);
const handleStartRequested = useCallback(async () => {
if (!canStartSession) {
return;
}
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
if (!currentSession) {
await startFocusFlow();
return;
}
const resumedSession = await resumeSession();
if (!resumedSession) {
pushStatusLine({
message: copy.space.workspace.resumeFailed,
});
}
}, [
canStartSession,
currentSession,
pushStatusLine,
resolveSoundPlaybackUrl,
resumeSession,
selectedPresetId,
startFocusFlow,
unlockPlayback,
]);
const handleExitRequested = useCallback(async () => {
const didAbandon = await abandonSession();
if (!didAbandon) {
pushStatusLine({
message: copy.space.workspace.abandonFailed,
});
return;
}
setPreviewPlaybackState('paused');
setPendingSessionEntryPoint('space-setup');
setWorkspaceMode('setup');
}, [abandonSession, pushStatusLine]);
const handlePauseRequested = useCallback(async () => {
if (!currentSession) {
setPreviewPlaybackState('paused');
return;
}
const pausedSession = await pauseSession();
if (!pausedSession) {
pushStatusLine({
message: copy.space.workspace.pauseFailed,
});
}
}, [currentSession, pauseSession, pushStatusLine]);
const handleRestartRequested = useCallback(async () => {
if (!currentSession) {
return;
}
const restartedSession = await restartCurrentPhase();
if (!restartedSession) {
pushStatusLine({
message: copy.space.workspace.restartFailed,
});
return;
}
pushStatusLine({
message: copy.space.workspace.restarted,
});
}, [currentSession, pushStatusLine, restartCurrentPhase]);
const handleGoalAdvance = useCallback(async (nextGoal: string) => {
const trimmedNextGoal = nextGoal.trim();
if (!trimmedNextGoal) {
return;
}
if (currentSession) {
const completedSession = await completeSession({
completionType: 'goal-complete',
completedGoal: goalInput.trim(),
});
if (!completedSession) {
pushStatusLine({
message: copy.space.workspace.goalCompleteSyncFailed,
});
return;
}
}
setGoalInput(trimmedNextGoal);
setSelectedGoalId(null);
setPendingSessionEntryPoint('goal-complete');
setPreviewPlaybackState('paused');
pushStatusLine({
message: copy.space.workspace.nextGoalReady,
});
}, [completeSession, currentSession, goalInput, pushStatusLine, setGoalInput, setSelectedGoalId]);
useEffect(() => {
const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow;
};
}, []);
useEffect(() => {
if (!currentSession) {
return;
}
const rafId = window.requestAnimationFrame(() => {
setPreviewPlaybackState(currentSession.state);
setWorkspaceMode('focus');
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [currentSession]);
useEffect(() => {
if (!isFocusMode || !queuedFocusStatusMessageRef.current) {
return;
}
const message = queuedFocusStatusMessageRef.current;
queuedFocusStatusMessageRef.current = null;
pushStatusLine({
message,
});
}, [isFocusMode, pushStatusLine]);
useEffect(() => {
if (!soundPlaybackError) {
lastSoundPlaybackErrorRef.current = null;
return;
}
if (soundPlaybackError === lastSoundPlaybackErrorRef.current) {
return;
}
lastSoundPlaybackErrorRef.current = soundPlaybackError;
pushStatusLine({
message: soundPlaybackError,
});
}, [pushStatusLine, soundPlaybackError]);
return {
workspaceMode,
isFocusMode,
previewPlaybackState,
resolvedPlaybackState,
canStartSession,
canPauseSession,
canRestartSession,
openFocusMode,
handleSetupFocusOpen,
handleStartRequested,
handleExitRequested,
handlePauseRequested,
handleRestartRequested,
handleGoalAdvance,
};
};

View File

@@ -0,0 +1,70 @@
'use client';
import { useEffect, useRef } from 'react';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import type { SceneTheme } from '@/entities/scene';
import type { SceneAssetManifestItem } from '@/entities/media';
interface UseWorkspaceMediaDiagnosticsParams {
mediaCatalogError: string | null;
pushStatusLine: (payload: HudStatusLinePayload) => void;
selectedScene: SceneTheme;
hasResolvedManifest: boolean;
usedFallbackManifest: boolean;
selectedSceneAsset: SceneAssetManifestItem | undefined;
}
export const useWorkspaceMediaDiagnostics = ({
mediaCatalogError,
pushStatusLine,
selectedScene,
hasResolvedManifest,
usedFallbackManifest,
selectedSceneAsset,
}: UseWorkspaceMediaDiagnosticsParams) => {
const lastMediaManifestErrorRef = useRef<string | null>(null);
const lastFallbackSceneDiagnosticRef = useRef<string | null>(null);
useEffect(() => {
if (!mediaCatalogError) {
lastMediaManifestErrorRef.current = null;
return;
}
if (mediaCatalogError === lastMediaManifestErrorRef.current) {
return;
}
lastMediaManifestErrorRef.current = mediaCatalogError;
console.error('[media] Failed to load remote media manifest.', {
error: mediaCatalogError,
sceneId: selectedScene.id,
});
pushStatusLine({
message: mediaCatalogError,
});
}, [mediaCatalogError, pushStatusLine, selectedScene.id]);
useEffect(() => {
if (!hasResolvedManifest || usedFallbackManifest) {
return;
}
const isUsingFallbackSceneAsset = !selectedSceneAsset || selectedSceneAsset.source === 'fallback';
if (!isUsingFallbackSceneAsset) {
lastFallbackSceneDiagnosticRef.current = null;
return;
}
if (lastFallbackSceneDiagnosticRef.current === selectedScene.id) {
return;
}
lastFallbackSceneDiagnosticRef.current = selectedScene.id;
console.warn('[space] Selected scene is using fallback asset data.', {
sceneId: selectedScene.id,
asset: selectedSceneAsset ?? null,
});
}, [hasResolvedManifest, selectedScene.id, selectedSceneAsset, usedFallbackManifest]);
};

View File

@@ -0,0 +1,60 @@
'use client';
import { useEffect } from 'react';
import { WORKSPACE_SELECTION_STORAGE_KEY } from './workspaceSelection';
import type { SelectionOverride } from './types';
import type { SceneTheme } from '@/entities/scene';
interface UseWorkspacePersistenceParams {
hasHydratedSelection: boolean;
selectedScene: SceneTheme;
selectedTimerPresetId: string | undefined;
selectedPresetId: string;
goalInput: string;
showResumePrompt: boolean;
resumeGoal: string;
selectionOverride: SelectionOverride;
}
export const useWorkspacePersistence = ({
hasHydratedSelection,
selectedScene,
selectedTimerPresetId,
selectedPresetId,
goalInput,
showResumePrompt,
resumeGoal,
selectionOverride,
}: UseWorkspacePersistenceParams) => {
useEffect(() => {
if (typeof window === 'undefined' || !hasHydratedSelection) {
return;
}
const normalizedGoal = goalInput.trim().length > 0
? goalInput.trim()
: showResumePrompt
? resumeGoal
: '';
window.localStorage.setItem(
WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({
sceneId: selectedScene.id,
timerPresetId: selectedTimerPresetId,
soundPresetId: selectedPresetId,
goal: normalizedGoal,
override: selectionOverride,
}),
);
}, [
goalInput,
hasHydratedSelection,
resumeGoal,
selectedPresetId,
selectedScene.id,
selectedTimerPresetId,
selectionOverride,
showResumePrompt,
]);
};

View File

@@ -0,0 +1,140 @@
import {
getSceneById,
normalizeSceneId,
SCENE_THEMES,
} from '@/entities/scene';
import {
SOUND_PRESETS,
TIMER_PRESETS,
type TimerPreset,
} from '@/entities/session';
export type SelectionOverride = {
sound: boolean;
timer: boolean;
};
export interface StoredWorkspaceSelection {
sceneId?: string;
timerPresetId?: string;
soundPresetId?: string;
goal?: string;
override?: Partial<SelectionOverride>;
}
export const WORKSPACE_SELECTION_STORAGE_KEY = 'viberoom:workspace-selection:v1';
export const readStoredWorkspaceSelection = (): StoredWorkspaceSelection => {
if (typeof window === 'undefined') {
return {};
}
const raw = window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY);
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return {};
}
return parsed as StoredWorkspaceSelection;
} catch {
return {};
}
};
export const resolveInitialSceneId = (
sceneIdFromQuery: string | null,
storedSceneId?: string,
) => {
const normalizedQuerySceneId = normalizeSceneId(sceneIdFromQuery);
if (normalizedQuerySceneId && getSceneById(normalizedQuerySceneId)) {
return normalizedQuerySceneId;
}
const normalizedStoredSceneId = normalizeSceneId(storedSceneId);
if (normalizedStoredSceneId && getSceneById(normalizedStoredSceneId)) {
return normalizedStoredSceneId;
}
return SCENE_THEMES[0].id;
};
export const resolveInitialSoundPreset = (
presetIdFromQuery: string | null,
storedPresetId: string | undefined,
recommendedPresetId?: string,
) => {
if (presetIdFromQuery && SOUND_PRESETS.some((preset) => preset.id === presetIdFromQuery)) {
return presetIdFromQuery;
}
if (storedPresetId && SOUND_PRESETS.some((preset) => preset.id === storedPresetId)) {
return storedPresetId;
}
if (recommendedPresetId && SOUND_PRESETS.some((preset) => preset.id === recommendedPresetId)) {
return recommendedPresetId;
}
return SOUND_PRESETS[0].id;
};
export const TIMER_SELECTION_PRESETS = TIMER_PRESETS.filter(
(preset): preset is TimerPreset & { focusMinutes: number; breakMinutes: number } =>
typeof preset.focusMinutes === 'number' && typeof preset.breakMinutes === 'number',
).slice(0, 3);
export const resolveTimerLabelFromPresetId = (presetId?: string) => {
if (!presetId) {
return null;
}
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.id === presetId);
if (!preset) {
return null;
}
return preset.label;
};
export const resolveTimerPresetIdFromLabel = (timerLabel: string) => {
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel);
return preset?.id;
};
export const resolveInitialTimerLabel = (
timerLabelFromQuery: string | null,
storedPresetId?: string,
recommendedPresetId?: string,
) => {
if (timerLabelFromQuery && TIMER_SELECTION_PRESETS.some((preset) => preset.label === timerLabelFromQuery)) {
return timerLabelFromQuery;
}
const storedLabel = resolveTimerLabelFromPresetId(storedPresetId);
if (storedLabel) {
return storedLabel;
}
const recommendedLabel = resolveTimerLabelFromPresetId(recommendedPresetId);
if (recommendedLabel) {
return recommendedLabel;
}
return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5';
};
export const resolveFocusTimeDisplayFromTimerLabel = (timerLabel: string) => {
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel);
const focusMinutes = preset?.focusMinutes ?? 25;
return `${String(focusMinutes).padStart(2, '0')}:00`;
};

File diff suppressed because it is too large Load Diff