Files
viberoom-web/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx

306 lines
11 KiB
TypeScript

'use client';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { SceneTheme } from '@/entities/scene';
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
import { SceneSelectCarousel } from '@/features/scene-select';
import { SessionGoalField } from '@/features/session-goal';
import { Button } from '@/shared/ui';
import { cn } from '@/shared/lib/cn';
type RitualPopover = 'space' | 'timer' | 'sound';
interface SpaceSetupDrawerWidgetProps {
open: boolean;
scenes: SceneTheme[];
selectedSceneId: string;
selectedTimerLabel: string;
selectedSoundPresetId: string;
goalInput: string;
selectedGoalId: string | null;
goalChips: GoalChip[];
soundPresets: SoundPreset[];
timerPresets: TimerPreset[];
canStart: boolean;
onSceneSelect: (sceneId: string) => void;
onTimerSelect: (timerLabel: string) => void;
onSoundSelect: (soundPresetId: string) => void;
onGoalChange: (value: string) => void;
onGoalChipSelect: (chip: GoalChip) => void;
onStart: () => void;
resumeHint?: {
goal: string;
onResume: () => void;
onStartFresh: () => void;
};
}
interface SummaryChipProps {
label: string;
value: string;
open: boolean;
onClick: () => void;
}
const SummaryChip = ({ label, value, open, onClick }: SummaryChipProps) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] transition-colors',
open
? 'border-sky-200/42 bg-sky-200/14 text-white/92'
: 'border-white/14 bg-white/[0.04] text-white/80 hover:bg-white/[0.09]',
)}
>
<span className="text-white/62">{label}</span>
<span className="max-w-[124px] truncate text-white/92">{value}</span>
<span aria-hidden className="text-white/56"></span>
</button>
);
};
export const SpaceSetupDrawerWidget = ({
open,
scenes,
selectedSceneId,
selectedTimerLabel,
selectedSoundPresetId,
goalInput,
selectedGoalId,
goalChips,
soundPresets,
timerPresets,
canStart,
onSceneSelect,
onTimerSelect,
onSoundSelect,
onGoalChange,
onGoalChipSelect,
onStart,
resumeHint,
}: SpaceSetupDrawerWidgetProps) => {
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const selectedScene = useMemo(() => {
return scenes.find((scene) => scene.id === selectedSceneId) ?? scenes[0];
}, [scenes, selectedSceneId]);
const selectedSoundLabel = useMemo(() => {
return (
soundPresets.find((preset) => preset.id === selectedSoundPresetId)?.label ??
soundPresets[0]?.label ??
'기본'
);
}, [selectedSoundPresetId, soundPresets]);
useEffect(() => {
if (!openPopover) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenPopover(null);
}
};
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (!panelRef.current?.contains(target)) {
setOpenPopover(null);
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('mousedown', handlePointerDown);
};
}, [openPopover]);
if (!open) {
return null;
}
const togglePopover = (popover: RitualPopover) => {
setOpenPopover((current) => (current === popover ? null : popover));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canStart) {
return;
}
onStart();
};
return (
<section
className="fixed left-1/2 top-1/2 z-40 w-[min(428px,92vw)] -translate-x-1/2 -translate-y-1/2"
aria-label="집중 시작 패널"
>
<div
ref={panelRef}
className="rounded-3xl border border-white/14 bg-[linear-gradient(160deg,rgba(15,23,42,0.68)_0%,rgba(8,13,27,0.56)_100%)] p-4 text-white shadow-[0_22px_52px_rgba(2,6,23,0.38)] backdrop-blur-2xl sm:p-5"
>
<header className="mb-3 space-y-1">
<p className="text-[10px] uppercase tracking-[0.18em] text-white/48">Ritual</p>
<h1 className="text-[1.45rem] font-semibold leading-tight text-white"> .</h1>
<p className="text-xs text-white/60"> HUD의 .</p>
</header>
{resumeHint ? (
<div className="mb-3 rounded-2xl border border-white/14 bg-black/22 px-3 py-2.5">
<p className="text-[11px] text-white/62"> </p>
<p className="mt-1 truncate text-sm text-white/88">{resumeHint.goal}</p>
<div className="mt-2 flex items-center justify-end gap-1.5">
<button
type="button"
onClick={resumeHint.onStartFresh}
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/72 transition-colors hover:bg-white/[0.1]"
>
</button>
<button
type="button"
onClick={resumeHint.onResume}
className="rounded-full border border-sky-200/34 bg-sky-200/14 px-2.5 py-1 text-[11px] text-white/90 transition-colors hover:bg-sky-200/22"
>
</button>
</div>
</div>
) : null}
<div className="relative mb-3">
<div className="flex flex-wrap gap-1.5">
<SummaryChip
label="배경"
value={selectedScene?.name ?? '기본 배경'}
open={openPopover === 'space'}
onClick={() => togglePopover('space')}
/>
<SummaryChip
label="타이머"
value={selectedTimerLabel}
open={openPopover === 'timer'}
onClick={() => togglePopover('timer')}
/>
<SummaryChip
label="사운드"
value={selectedSoundLabel}
open={openPopover === 'sound'}
onClick={() => togglePopover('sound')}
/>
</div>
{openPopover === 'space' ? (
<div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-2.5 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
<SceneSelectCarousel
scenes={scenes.slice(0, 4)}
selectedSceneId={selectedSceneId}
onSelect={(sceneId) => {
onSceneSelect(sceneId);
setOpenPopover(null);
}}
/>
</div>
) : null}
{openPopover === 'timer' ? (
<div className="absolute left-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
<div className="flex flex-wrap gap-1.5">
{timerPresets.slice(0, 3).map((preset) => {
const selected = preset.label === selectedTimerLabel;
return (
<button
key={preset.id}
type="button"
onClick={() => {
onTimerSelect(preset.label);
setOpenPopover(null);
}}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)}
>
{preset.label}
</button>
);
})}
</div>
</div>
) : null}
{openPopover === 'sound' ? (
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-20 w-[min(300px,88vw)] rounded-2xl border border-white/14 bg-slate-950/80 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
<div className="flex flex-wrap gap-1.5">
{soundPresets.slice(0, 6).map((preset) => {
const selected = preset.id === selectedSoundPresetId;
return (
<button
key={preset.id}
type="button"
onClick={() => {
onSoundSelect(preset.id);
setOpenPopover(null);
}}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)}
>
{preset.label}
</button>
);
})}
</div>
</div>
) : null}
</div>
<form id="space-setup-ritual-form" className="space-y-3" onSubmit={handleSubmit}>
<SessionGoalField
autoFocus={open}
goalInput={goalInput}
selectedGoalId={selectedGoalId}
goalChips={goalChips}
onGoalChange={onGoalChange}
onGoalChipSelect={onGoalChipSelect}
/>
<div className="space-y-1.5 pt-1">
{!canStart ? <p className="text-[10px] text-white/56"> .</p> : null}
<Button
type="submit"
form="space-setup-ritual-form"
size="full"
disabled={!canStart}
className={cn(
'h-10 rounded-xl !bg-sky-300/84 !text-slate-900 shadow-[0_8px_16px_rgba(125,211,252,0.24)] hover:!bg-sky-300 disabled:!bg-white/10 disabled:!text-white/42',
)}
>
</Button>
</div>
</form>
</div>
</section>
);
};