93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
"use client"
|
||
import { useState } from "react"
|
||
import { motion, AnimatePresence } from "framer-motion"
|
||
import { Check, Plus } from "lucide-react"
|
||
import { type KeyFrame } from "@/lib/api"
|
||
|
||
interface Props {
|
||
frames: KeyFrame[]
|
||
selected: Set<number>
|
||
onToggle: (frameIndex: number) => void
|
||
maxSelect?: number
|
||
}
|
||
|
||
function formatTimestamp(t: number) {
|
||
const m = Math.floor(t / 60)
|
||
const s = Math.floor(t % 60)
|
||
return `${m}:${s.toString().padStart(2, "0")}`
|
||
}
|
||
|
||
export function KeyframeGallery({ frames, selected, onToggle, maxSelect = 10 }: Props) {
|
||
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
|
||
|
||
if (frames.length === 0) {
|
||
return (
|
||
<div className="glass-card flex h-64 items-center justify-center text-white/30 text-sm">
|
||
关键帧将在下载完成后出现
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="relative">
|
||
<div className="absolute -top-8 right-0 text-xs text-white/40">
|
||
已选 <span className="text-white/80 font-medium">{selected.size}</span> / {maxSelect}
|
||
</div>
|
||
<div className="flex gap-3 overflow-x-auto pb-3 -mx-2 px-2 snap-x snap-mandatory">
|
||
{frames.map((f) => {
|
||
const isSelected = selected.has(f.index)
|
||
const isHover = hoverIdx === f.index
|
||
const disabled = !isSelected && selected.size >= maxSelect
|
||
return (
|
||
<motion.button
|
||
key={f.index}
|
||
type="button"
|
||
onClick={() => !disabled && onToggle(f.index)}
|
||
onMouseEnter={() => setHoverIdx(f.index)}
|
||
onMouseLeave={() => setHoverIdx(null)}
|
||
disabled={disabled}
|
||
animate={{
|
||
scale: isSelected ? 1 : isHover ? 0.98 : 0.92,
|
||
opacity: isSelected ? 1 : disabled ? 0.3 : isHover ? 0.95 : 0.7,
|
||
}}
|
||
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
|
||
className="relative shrink-0 snap-start rounded-lg overflow-hidden border border-white/10 disabled:cursor-not-allowed"
|
||
style={{ width: 200, height: 120 }}
|
||
>
|
||
<img
|
||
src={f.url}
|
||
alt={`frame ${f.index}`}
|
||
className="w-full h-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
{/* 选中态:ambient glow */}
|
||
<AnimatePresence>
|
||
{isSelected && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="absolute inset-0 ring-2 ring-white rounded-lg shadow-[0_0_30px_rgba(255,255,255,0.4)]"
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
{/* 时间戳 */}
|
||
<div className="absolute bottom-1.5 left-1.5 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-mono text-white/90">
|
||
{formatTimestamp(f.timestamp)}
|
||
</div>
|
||
{/* 选中标记 */}
|
||
<div
|
||
className={`absolute top-1.5 right-1.5 h-6 w-6 rounded-full flex items-center justify-center ${
|
||
isSelected ? "bg-white text-black" : "bg-black/50 text-white/60 border border-white/20"
|
||
}`}
|
||
>
|
||
{isSelected ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||
</div>
|
||
</motion.button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|