303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|