ai-chatbot/components/home/lovart/lovart-hero-composer.tsx

303 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import dynamic from "next/dynamic";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { ModelIdentityIcon } from "@/components/lovart/model-identity-icon";
import { Lightbulb, Loader2, X } from "lucide-react";
import { useRef, type ChangeEvent } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { ModelOption, UploadFileItem } from "./types";
const HomeAnimationEmbed = dynamic(
() =>
import("@/components/home/home-animation-embed").then(
(module) => module.HomeAnimationEmbed
),
{
loading: () => (
<div className="h-[340px] w-full rounded-[16px] border border-black/10 bg-black/[0.03] md:h-[400px] dark:border-white/10 dark:bg-white/[0.04]" />
),
ssr: false,
}
);
type LovartHeroComposerProps = {
composerPlaceholder: string;
displayLogo: string;
files: UploadFileItem[];
handleAddFiles: (files: FileList | null) => void;
heroPromoActionText?: string | null;
heroPromoText?: string | null;
heroSubtitleText?: string | null;
heroTitleText?: string | null;
homeAnimationData?: {
enabled?: boolean;
htmlContent?: string;
fileName?: string;
};
isAgentModeActive: boolean;
isMembershipEnabled: boolean;
isSubmitting: boolean;
onSubmit: () => void;
openUpgradeDialog: () => void;
prompt: string;
removeFile: (id: string) => void;
setPrompt: (value: string) => void;
siteName: string;
modelOptions: ModelOption[];
onModelChange: (modelId: string) => void;
onStartAgentMode: () => void;
selectedModelId: string;
onOpenGallery: () => void;
};
export function LovartHeroComposer({
composerPlaceholder,
files,
handleAddFiles,
heroSubtitleText,
heroTitleText,
homeAnimationData,
isAgentModeActive,
isSubmitting,
onSubmit,
prompt,
removeFile,
setPrompt,
siteName,
modelOptions,
onModelChange,
onStartAgentMode,
selectedModelId,
onOpenGallery,
}: LovartHeroComposerProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const uploadRef = useRef<HTMLInputElement>(null);
const suggestions = [
{
label: "电商",
icon: "commerce" as const,
prompt: "为我的产品设计一张高转化的电商主图,突出产品卖点。",
},
{
label: "社交媒体",
icon: "social" as const,
prompt: "设计一张适合社交媒体发布的视觉海报。",
},
{
label: "品牌",
icon: "branding" as const,
prompt: "为我的品牌设计一套统一的视觉形象。",
},
{
label: "创意",
icon: "creativity" as const,
prompt: "创作一张富有想象力和视觉冲击力的创意作品。",
},
{
label: "网站",
icon: "website" as const,
prompt: "设计一个简洁、现代的网站首页视觉稿。",
},
];
return (
<section className="lovart-hero mt-6 text-center" aria-label="开始设计">
<h1 className="text-[20px] font-medium leading-7 tracking-[-0.02em] dark:text-white">
{heroTitleText || "你想设计什么?"}
</h1>
{heroSubtitleText ? (
<p className="mt-2 text-[13px] text-black/45 dark:text-white/45">
{heroSubtitleText}
</p>
) : null}
<div className="lovart-home-composer mx-auto mt-7 w-full max-w-[760px] rounded-[24px] border border-[#e5e5e5] bg-white px-[16px] pb-[8px] pt-[14px] shadow-[0_2px_4px_rgba(0,0,0,0.04),0_8px_20px_rgba(0,0,0,0.025)] transition-shadow duration-150 focus-within:shadow-[0_3px_8px_rgba(0,0,0,0.07)] dark:border-white/12 dark:bg-[#141416]">
<Textarea
ref={textareaRef}
aria-label="描述你的设计需求"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={composerPlaceholder}
className="min-h-[54px] resize-none border-0 bg-transparent px-0 !text-[16px] leading-6 text-[#20242a] shadow-none placeholder:text-[#b7b7b7] focus-visible:ring-0 md:!text-[14px] dark:text-white dark:placeholder:text-white/35"
/>
{files.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-1">
{files.map((item) => (
<div
key={item.id}
className="group relative h-11 w-11 overflow-hidden rounded-md border border-black/10 dark:border-white/10"
>
<img
src={item.previewUrl}
alt={item.file.name}
className="h-full w-full object-cover"
/>
<button
type="button"
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-white group-hover:flex"
onClick={() => removeFile(item.id)}
aria-label={`移除${item.file.name}`}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div className="flex min-h-[44px] items-center justify-between pt-2">
<div className="flex min-w-0 flex-1 items-center gap-4">
<label className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-[#20242a] transition-colors duration-150 hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8">
<ReferenceIcon name="plus" size={18} />
<input
ref={uploadRef}
aria-label="上传参考图"
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleAddFiles(event.target.files);
event.target.value = "";
}}
/>
</label>
</div>
<div className="flex h-9 items-center gap-3 pl-2">
<button
type="button"
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-full p-0 leading-none transition-colors duration-150 focus:outline-none focus:ring-0",
isAgentModeActive
? "bg-[#eaf1ff] text-[#2563eb] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.16)] dark:bg-[#102042] dark:text-[#8db4ff] dark:shadow-[inset_0_0_0_1px_rgba(141,180,255,0.18)]"
: "text-[#20242a] hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8"
)}
aria-label="开启 Agent 模式"
onClick={onStartAgentMode}
title="开启 Agent 模式"
>
<Lightbulb className="block h-4 w-4" strokeWidth={1.9} />
</button>
<Select value={selectedModelId} onValueChange={onModelChange}>
<SelectTrigger
aria-label="选择模型"
className="!flex !h-8 !w-8 shrink-0 !items-center !justify-center rounded-full border-0 !bg-transparent !p-0 text-[#20242a] leading-none shadow-none transition-colors duration-150 hover:bg-black/5 focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white/72 dark:hover:bg-white/8 [&>svg]:hidden"
title="选择模型"
>
<div className="flex h-8 w-8 items-center justify-center">
<ModelIdentityIcon
name={
modelOptions.find((model) => model.id === selectedModelId)
?.displayName || "auto"
}
avatarUrl={
modelOptions.find((model) => model.id === selectedModelId)
?.avatarUrl
}
size={18}
/>
</div>
</SelectTrigger>
<SelectContent
align="end"
sideOffset={10}
className="w-[200px] rounded-[12px] border-black/10 bg-white p-1 text-[#20242a] shadow-[0_18px_44px_rgba(15,23,42,0.14)] dark:border-white/10 dark:bg-[#141416] dark:text-white"
>
{modelOptions.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="h-8 rounded-[7px] text-[12px] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full"
>
<span className="flex min-w-0 items-center gap-2">
<ModelIdentityIcon
name={model.displayName}
identity={`${model.modelId || model.id} ${model.providerType || ""}`}
avatarUrl={model.avatarUrl}
size={18}
/>
<span className="min-w-0 flex-1 truncate">
{model.displayName}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={onSubmit}
aria-label="开始生成"
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-9 w-9 rounded-full bg-[#eeeeef] p-0 text-[#c2c7cf] transition-colors duration-150 hover:bg-[#e5e6e8] hover:text-[#7f8794] dark:bg-white/10 dark:text-white/40 dark:hover:bg-white/16"
>
{isSubmitting ? (
<Loader2 className="h-4.5 w-4.5 animate-spin" />
) : (
<ReferenceIcon name="arrow-up" size={18} />
)}
</Button>
</div>
</div>
</div>
<div className="mx-auto mt-4 w-full max-w-[760px] space-y-3 px-4 text-left">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 w-[96px] shrink-0 text-[13px] text-[#8b8b8b] dark:text-white/45">
{siteName} 帮你做:
</span>
{suggestions.map(({ label, icon, prompt: suggestion }) => (
<button
key={label}
type="button"
className="lovart-suggestion"
onClick={() => {
setPrompt(suggestion);
textareaRef.current?.focus();
}}
>
<ReferenceIcon name={icon} size={18} />
{label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 w-[96px] shrink-0 text-[13px] text-[#8b8b8b] dark:text-white/45">
连接/收集灵感:
</span>
<button
type="button"
className="lovart-suggestion"
onClick={() => uploadRef.current?.click()}
>
<ReferenceIcon name="connections" size={18} />
上传我的参考素材
</button>
<button
type="button"
className="lovart-suggestion"
onClick={onOpenGallery}
>
<ReferenceIcon name="clipper" size={18} />
浏览灵感,发现更多创意
</button>
</div>
</div>
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
<div className="mt-6 w-full">
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />
</div>
) : null}
</section>
);
}