ghp / packages /client /src /components /InteractionGate.tsx
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
4.4 kB
import { motion, useReducedMotion } from 'motion/react'
import { Headphones, Lock, UserRound } from 'lucide-react'
import { LIMITS } from '@music-together/shared'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useState } from 'react'
import { storage } from '@/lib/storage'
interface InteractionGateProps {
onStart: (password?: string) => void
roomName?: string
hasPassword?: boolean
passwordError?: string | null
}
export function InteractionGate({ onStart, roomName, hasPassword, passwordError }: InteractionGateProps) {
const prefersReducedMotion = useReducedMotion()
const [password, setPassword] = useState('')
const savedNickname = storage.getNickname()
const needsNickname = !savedNickname
const [nickname, setNickname] = useState(savedNickname)
const canStart = (!needsNickname || nickname.trim().length > 0) && (!hasPassword || password.trim().length > 0)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canStart) return
if (needsNickname) {
storage.setNickname(nickname.trim())
}
onStart(hasPassword ? password.trim() : undefined)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background px-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="flex w-full max-w-sm flex-col items-center gap-6 rounded-2xl border border-border bg-card p-10 shadow-lg"
>
<motion.div
animate={prefersReducedMotion ? {} : { rotate: [0, 5, -5, 0] }}
transition={prefersReducedMotion ? {} : { duration: 4, repeat: Infinity, ease: 'easeInOut' }}
>
<Headphones className="h-16 w-16 text-primary" />
</motion.div>
<div className="flex flex-col items-center gap-1.5">
<h2 className="text-xl font-semibold">准备就绪</h2>
{roomName ? (
<p className="text-sm text-muted-foreground">即将加入「{roomName}」</p>
) : (
<p className="text-sm text-muted-foreground">点击开始,与房间好友一起听歌</p>
)}
</div>
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-4">
{needsNickname && (
<div className="flex flex-col gap-2">
<Label htmlFor="gate-nickname" className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UserRound className="h-3.5 w-3.5" />
<span>设置你的昵称</span>
</Label>
<Input
id="gate-nickname"
placeholder="你的昵称..."
value={nickname}
onChange={(e) => setNickname(e.target.value)}
maxLength={LIMITS.NICKNAME_MAX_LENGTH}
autoFocus
/>
</div>
)}
{hasPassword && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="h-3.5 w-3.5" />
<span>该房间需要密码</span>
</div>
<motion.div animate={passwordError ? { x: [-8, 8, -6, 6, -3, 3, 0] } : {}} transition={{ duration: 0.5 }}>
<Input
type="password"
placeholder="输入房间密码..."
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus={!needsNickname}
className={passwordError ? 'border-destructive' : ''}
/>
{passwordError && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1.5 text-xs text-destructive"
>
{passwordError}
</motion.p>
)}
</motion.div>
</div>
)}
<Button type="submit" size="lg" className="w-full" disabled={!canStart} aria-label="开始收听">
开始收听
</Button>
</form>
</motion.div>
</div>
)
}