feat(space): thought orb capture ui 추가
This commit is contained in:
115
src/widgets/space-focus-hud/ui/ThoughtOrb.tsx
Normal file
115
src/widgets/space-focus-hud/ui/ThoughtOrb.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
|
interface ThoughtOrbProps {
|
||||||
|
isFocusMode: boolean;
|
||||||
|
onCaptureThought: (note: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThoughtOrb = ({ isFocusMode, onCaptureThought }: ThoughtOrbProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [isAbsorbing, setIsAbsorbing] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
setDraft('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isFocusMode) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAbsorbing(true);
|
||||||
|
onCaptureThought(trimmed);
|
||||||
|
|
||||||
|
// Provide a satisfying "sucking" animation delay before closing
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setDraft('');
|
||||||
|
setIsAbsorbing(false);
|
||||||
|
}, 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-8 right-8 z-40 flex flex-col items-end gap-3 pointer-events-none">
|
||||||
|
<div className="pointer-events-auto relative group">
|
||||||
|
{/* The Orb */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-14 w-14 items-center justify-center rounded-full transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]",
|
||||||
|
isOpen || isAbsorbing
|
||||||
|
? "bg-white text-black shadow-[0_0_60px_rgba(255,255,255,0.8)] scale-110"
|
||||||
|
: "bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.15),transparent)] border border-white/10 text-white/70 hover:bg-white/10 hover:text-white hover:scale-105 hover:shadow-[0_0_30px_rgba(255,255,255,0.2)] backdrop-blur-md",
|
||||||
|
isAbsorbing && "animate-pulse"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("absolute inset-0 rounded-full bg-white/30 blur-xl transition-opacity duration-700", (isOpen || isAbsorbing) ? "opacity-100" : "opacity-0 group-hover:opacity-60")} />
|
||||||
|
<svg viewBox="0 0 24 24" className="h-6 w-6 relative z-10 transition-transform duration-500" style={{ transform: isOpen ? 'rotate(45deg)' : 'rotate(0deg)' }} 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={cn(
|
||||||
|
"absolute right-full top-1/2 -translate-y-1/2 mr-4 pointer-events-none transition-all duration-300",
|
||||||
|
(!isOpen && !isAbsorbing) ? "opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0" : "opacity-0"
|
||||||
|
)}>
|
||||||
|
<span className="bg-black/40 backdrop-blur-xl border border-white/5 text-white/80 text-[10px] uppercase tracking-widest px-3 py-1.5 rounded-full whitespace-nowrap shadow-xl">
|
||||||
|
Brain Dump
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Field */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] origin-top-right",
|
||||||
|
isOpen
|
||||||
|
? "opacity-100 scale-100 translate-y-0"
|
||||||
|
: "opacity-0 scale-95 -translate-y-4 pointer-events-none",
|
||||||
|
isAbsorbing && "scale-50 opacity-0 blur-md translate-x-10 -translate-y-10" // "Sucked into the orb" animation
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="relative w-[22rem]">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(255,255,255,0.1)_0%,rgba(255,255,255,0.02)_100%)] rounded-[2rem] backdrop-blur-3xl shadow-[0_20px_40px_rgba(0,0,0,0.4)] border border-white/10" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
disabled={isAbsorbing}
|
||||||
|
placeholder="Dump a distracting thought..."
|
||||||
|
className="relative z-10 w-full bg-transparent px-6 py-5 text-[15px] font-medium text-white outline-none placeholder:text-white/30 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="hidden">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user