260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { motion } from "framer-motion";
|
|
import { Copy, Pencil, Sparkles, Trash2, Video, Wand2, FileText } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import type { AgentPreset } from "@/types/agent";
|
|
import {
|
|
AGENT_PRESET_PREVIEW_IMAGE_SIZES,
|
|
getAgentPresetPreviewImageUrl,
|
|
} from "./agent-preset-preview";
|
|
|
|
interface AgentPresetCardNewProps {
|
|
preset: AgentPreset;
|
|
selected?: boolean;
|
|
onSelect: (presetId: string) => void;
|
|
onFork?: (preset: AgentPreset) => void;
|
|
onEdit?: (preset: AgentPreset) => void;
|
|
onDelete?: (preset: AgentPreset) => void;
|
|
index?: number;
|
|
}
|
|
|
|
export function AgentPresetCardNew({
|
|
preset,
|
|
selected = false,
|
|
onSelect,
|
|
onFork,
|
|
onEdit,
|
|
onDelete,
|
|
index = 0,
|
|
}: AgentPresetCardNewProps) {
|
|
const rawPreviewUrl =
|
|
preset.avatarUrl ||
|
|
preset.referenceAssets?.[0]?.thumbnailUrl ||
|
|
preset.referenceAssets?.[0]?.originalUrl ||
|
|
"";
|
|
const previewUrl = getAgentPresetPreviewImageUrl(rawPreviewUrl);
|
|
|
|
const ownerName =
|
|
String(preset.owner?.name || "").trim() ||
|
|
String(preset.owner?.email || "").trim() ||
|
|
"未知发布人";
|
|
|
|
const publishedAt = (() => {
|
|
const raw = preset.createdAt ? new Date(preset.createdAt) : null;
|
|
if (!raw || Number.isNaN(raw.getTime())) return "未知时间";
|
|
return raw.toLocaleDateString("zh-CN", {
|
|
timeZone: "Asia/Shanghai",
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
});
|
|
})();
|
|
|
|
const typeConfig = {
|
|
image: { icon: Wand2, label: "生图", color: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400" },
|
|
video: { icon: Video, label: "视频", color: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
|
|
text: { icon: FileText, label: "生文", color: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" },
|
|
};
|
|
|
|
const typeInfo = typeConfig[preset.type] || typeConfig.image;
|
|
const TypeIcon = typeInfo.icon;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{
|
|
duration: 0.4,
|
|
delay: index * 0.05,
|
|
ease: [0.25, 0.46, 0.45, 0.94]
|
|
}}
|
|
whileHover={{ y: -4 }}
|
|
className={cn(
|
|
"group relative mx-auto w-full max-w-[230px] overflow-hidden rounded-2xl bg-white transition-all duration-300",
|
|
"border border-slate-200/60 dark:border-slate-800/60",
|
|
"shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_2px_8px_rgba(0,0,0,0.04)]",
|
|
"hover:shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_32px_rgba(0,0,0,0.08)]",
|
|
"hover:border-indigo-200/60 dark:hover:border-indigo-800/40",
|
|
selected && [
|
|
"border-indigo-500/50 dark:border-indigo-400/50",
|
|
"shadow-[0_0_0_1px_rgba(99,102,241,0.2),0_8px_24px_rgba(99,102,241,0.12)]",
|
|
"ring-1 ring-indigo-500/20",
|
|
]
|
|
)}
|
|
>
|
|
{/* 顶部选择指示条 */}
|
|
<div
|
|
className={cn(
|
|
"absolute left-0 right-0 top-0 h-[3px] transition-all duration-300",
|
|
selected
|
|
? "bg-gradient-to-r from-indigo-500 via-violet-500 to-indigo-500"
|
|
: "bg-transparent group-hover:bg-gradient-to-r group-hover:from-indigo-500/30 group-hover:via-violet-500/30 group-hover:to-indigo-500/30"
|
|
)}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
className="block w-full text-left"
|
|
onClick={() => onSelect(preset.id)}
|
|
>
|
|
{/* 预览图区域 */}
|
|
<div className="relative aspect-[4/3] w-full overflow-hidden bg-slate-50 dark:bg-slate-900/50">
|
|
{previewUrl ? (
|
|
<motion.img
|
|
src={previewUrl}
|
|
alt={preset.name}
|
|
loading="lazy"
|
|
decoding="async"
|
|
fetchPriority="low"
|
|
sizes={AGENT_PRESET_PREVIEW_IMAGE_SIZES}
|
|
className="h-full w-full object-cover"
|
|
whileHover={{ scale: 1.05 }}
|
|
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
|
|
<div className={cn(
|
|
"flex h-16 w-16 items-center justify-center rounded-2xl",
|
|
typeInfo.color
|
|
)}>
|
|
<TypeIcon className="h-7 w-7" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 类型标签 */}
|
|
<div className="absolute left-3 top-3">
|
|
<Badge
|
|
variant="secondary"
|
|
className={cn(
|
|
"border-0 px-2.5 py-1 text-[11px] font-medium backdrop-blur-md",
|
|
"bg-white/85 text-slate-700 shadow-sm",
|
|
"dark:bg-slate-950/80 dark:text-slate-200"
|
|
)}
|
|
>
|
|
<TypeIcon className="mr-1 h-3 w-3" />
|
|
{typeInfo.label}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* 可见性标签 */}
|
|
<div className="absolute right-3 top-3">
|
|
<Badge
|
|
variant="secondary"
|
|
className={cn(
|
|
"border-0 px-2 py-0.5 text-[10px] font-medium backdrop-blur-md",
|
|
preset.visibility === "public"
|
|
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
|
: "bg-slate-500/10 text-slate-700 dark:text-slate-400"
|
|
)}
|
|
>
|
|
{preset.visibility === "public" ? "公共" : "私有"}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* 悬浮操作按钮 */}
|
|
<motion.div
|
|
className="absolute inset-x-3 bottom-3 flex items-center justify-end gap-1.5"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.05 + 0.2 }}
|
|
>
|
|
<div className="flex gap-1.5 opacity-0 transition-all duration-200 group-hover:opacity-100">
|
|
{preset.visibility === "private" && onEdit && (
|
|
<motion.button
|
|
type="button"
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-slate-700 shadow-lg backdrop-blur-sm transition-colors hover:bg-white hover:text-indigo-600 dark:bg-slate-900/95 dark:text-slate-300 dark:hover:text-indigo-400"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onEdit(preset);
|
|
}}
|
|
title="编辑"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</motion.button>
|
|
)}
|
|
{preset.visibility === "private" && onDelete && (
|
|
<motion.button
|
|
type="button"
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-slate-700 shadow-lg backdrop-blur-sm transition-colors hover:bg-white hover:text-rose-600 dark:bg-slate-900/95 dark:text-slate-300 dark:hover:text-rose-400"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onDelete(preset);
|
|
}}
|
|
title="删除"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</motion.button>
|
|
)}
|
|
{preset.visibility === "public" && onFork && (
|
|
<motion.button
|
|
type="button"
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-slate-700 shadow-lg backdrop-blur-sm transition-colors hover:bg-white hover:text-indigo-600 dark:bg-slate-900/95 dark:text-slate-300 dark:hover:text-indigo-400"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onFork(preset);
|
|
}}
|
|
title="另存为我的风格化"
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</motion.button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* 信息区域 */}
|
|
<div className="space-y-2.5 p-4">
|
|
<div>
|
|
<h3 className="line-clamp-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
|
|
{preset.name}
|
|
</h3>
|
|
{preset.description && (
|
|
<p className="mt-1 line-clamp-1 text-xs text-slate-500 dark:text-slate-400">
|
|
{preset.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between pt-1">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-100 text-[10px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
|
{ownerName.charAt(0).toUpperCase()}
|
|
</div>
|
|
<span className="max-w-[80px] truncate text-[11px] text-slate-500 dark:text-slate-400">
|
|
{ownerName}
|
|
</span>
|
|
</div>
|
|
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
|
{publishedAt}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 槽位信息 */}
|
|
{preset.slotCount > 0 && (
|
|
<div className="flex items-center gap-3 border-t border-slate-100 pt-2.5 dark:border-slate-800">
|
|
<div className="flex items-center gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
|
<Sparkles className="h-3 w-3" />
|
|
<span>{preset.slotCount} 个槽位</span>
|
|
</div>
|
|
{preset.referenceAssetCount && preset.referenceAssetCount > 0 && (
|
|
<div className="flex items-center gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
|
<span>{preset.referenceAssetCount} 张参考图</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
</motion.div>
|
|
);
|
|
}
|