refactor: split lovart home into focused sections

This commit is contained in:
Your Name 2026-05-26 20:52:09 +08:00
parent 104187e2fe
commit 501f5cf0d8
7 changed files with 1095 additions and 686 deletions

View File

@ -17,7 +17,6 @@ import {
Clapperboard,
Edit,
Folder,
Heart,
Home,
ImageIcon,
Info,
@ -33,7 +32,6 @@ import {
Sparkles,
Star,
Sun,
Trash2,
Upload,
User,
Users,
@ -54,25 +52,25 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import type { ImageGenerationParams } from "@/components/image-params-bar";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn, fetcher } from "@/lib/utils";
import {
type CanvasLaunchAttachment,
getCanvasLaunchStorageKey,
} from "@/lib/canvas-launch";
import type { MembershipConfig } from "@/lib/membership/config";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import { FloatingSidebarRail } from "@/components/home/floating-sidebar-shell";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
normalizeLovartHomeSidebarConfig,
type LovartHomeSidebarItemKey,
} from "@/lib/home/lovart-sidebar-config";
import { BrandTopBar } from "@/components/layout/brand-top-bar";
import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-gallery";
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
import { LovartSidebar } from "@/components/home/lovart/lovart-sidebar";
type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator;
@ -318,7 +316,6 @@ const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
type LovartPromptTagKey = (typeof PROMPT_TAGS)[number]["key"];
type LovartPromptTagVisibility = Record<LovartPromptTagKey, boolean>;
const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
const FEATURED_PAGE_SIZE = 24;
const COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
@ -373,19 +370,6 @@ function getUserInitials(user: HomeUser | null) {
return source.slice(0, 2).toLowerCase();
}
function formatRelativeTime(value?: string) {
if (!value) return "更新于今天";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "更新于今天";
const diffMs = Date.now() - date.getTime();
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diffMs < hour) return `更新于 ${Math.max(1, Math.round(diffMs / minute))} 分钟前`;
if (diffMs < day) return `更新于 ${Math.round(diffMs / hour)} 小时前`;
return `更新于 ${Math.round(diffMs / day)} 天前`;
}
function formatPrice(currency: string, value: number) {
if (value <= 0) return "免费";
return `${currency}${value}`;
@ -1745,678 +1729,93 @@ export function LovartHome({
)}
</BrandTopBar>
<aside className="fixed left-5 top-[88px] z-30 hidden flex-col items-center gap-3 lg:flex">
<TooltipProvider delayDuration={120}>
{isLovartSidebarItemEnabled("newProject") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={openNewCanvas}
className="flex h-[56px] w-[56px] shrink-0 items-center justify-center rounded-[20px] border border-black/8 bg-white/96 shadow-[0_20px_46px_rgba(15,23,42,0.12)] backdrop-blur-xl transition-transform duration-200 hover:-translate-y-0.5 hover:bg-white"
title={getLovartSidebarItem("newProject").label}
>
<div className="flex h-9 w-9 items-center justify-center rounded-[12px] bg-[#111827] text-white shadow-[0_10px_20px_rgba(15,23,42,0.18)]">
{renderLovartSidebarIcon("newProject", {
filled: true,
sizeClassName: "h-4 w-4",
})}
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("newProject").label}
</TooltipContent>
</Tooltip>
) : null}
<FloatingSidebarRail className="max-h-[calc(100vh-172px)]" tone="light">
{isLovartSidebarItemEnabled("home") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass(!isProjectsOnlyView)}
onClick={() => {
setIsProjectsOnlyView(false);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
title={getLovartSidebarItem("home").label}
>
{renderLovartSidebarIcon("home", {
active: !isProjectsOnlyView,
})}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("home").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("projects") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass(isProjectsOnlyView)}
onClick={openAllProjectsInPage}
title={getLovartSidebarItem("projects").label}
>
{renderLovartSidebarIcon("projects", {
active: isProjectsOnlyView,
})}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("projects").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("profile") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={() => toast.info("个人主页模块后续开放")}
title={getLovartSidebarItem("profile").label}
>
{renderLovartSidebarIcon("profile")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("profile").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("gallery") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={() => router.push("/gallery")}
title={getLovartSidebarItem("gallery").label}
>
{renderLovartSidebarIcon("gallery")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("gallery").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("tutorial") &&
siteSettingsData?.tutorialEnabled ? (
<HoverCard openDelay={80} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
title={getLovartSidebarItem("tutorial").label}
>
{renderLovartSidebarIcon("tutorial")}
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
className={`w-[300px] ${sidebarPopupBaseClass}`}
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
{getLovartSidebarItem("tutorial").label}
</div>
<div className="max-h-[380px] space-y-1 overflow-y-auto">
{tutorialVideos.length > 0 ? (
tutorialVideos.map((video) => (
<button
key={video.id}
type="button"
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04]"
onClick={() => handleOpenTutorial(video)}
>
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04]">
{video.coverUrl ? (
<img
src={video.coverUrl}
alt={video.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<PlayCircle className="h-4 w-4 text-black/35" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-black/85">
{video.title}
</div>
{video.description ? (
<div className="truncate text-[11px] text-black/42">
{video.description}
</div>
) : null}
</div>
</button>
))
) : (
<div className="px-2 py-3 text-[12px] text-black/42">暂无教程视频</div>
)}
</div>
</HoverCardContent>
</HoverCard>
) : null}
{isLovartSidebarItemEnabled("imageEditor") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={handleOpenImageEditor}
title={getLovartSidebarItem("imageEditor").label}
>
{renderLovartSidebarIcon("imageEditor")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("imageEditor").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("aiCad") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={() => router.push("/ai-cad")}
title={getLovartSidebarItem("aiCad").label}
>
{renderLovartSidebarIcon("aiCad")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("aiCad").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("agent") &&
siteSettingsData?.agentEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={() => router.push("/canvas?mode=agent")}
title={getLovartSidebarItem("agent").label}
>
{renderLovartSidebarIcon("agent")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("agent").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("videoCanvas") &&
siteSettingsData?.videoCanvasEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={() => router.push("/video-canvas")}
title={getLovartSidebarItem("videoCanvas").label}
>
{renderLovartSidebarIcon("videoCanvas")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("videoCanvas").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("flowBoard") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={() => router.push("/flow-board")}
title={getLovartSidebarItem("flowBoard").label}
>
{renderLovartSidebarIcon("flowBoard")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("flowBoard").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("productPlanning") &&
siteSettingsData?.productPlanningEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={() => router.push("/product-planning")}
title={getLovartSidebarItem("productPlanning").label}
>
{renderLovartSidebarIcon("productPlanning")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("productPlanning").label}
</TooltipContent>
</Tooltip>
) : null}
{isLovartSidebarItemEnabled("toolbox1") &&
siteSettingsData?.toolbox1Enabled !== false &&
toolbox1Links.length > 0 ? (
<HoverCard openDelay={80} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
title={getLovartSidebarItem("toolbox1").label}
>
{renderLovartSidebarIcon("toolbox1")}
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
className={`w-[248px] ${sidebarPopupBaseClass}`}
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
{getLovartSidebarItem("toolbox1").label}
</div>
<div className="space-y-1">
{toolbox1Links.map((link) => (
<button
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04]"
onClick={() => openNavLink(link.url)}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/30" />
</button>
))}
</div>
</HoverCardContent>
</HoverCard>
) : null}
{isLovartSidebarItemEnabled("toolbox2") &&
siteSettingsData?.toolbox2Enabled === true &&
toolbox2Links.length > 0 ? (
<HoverCard openDelay={80} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
title={getLovartSidebarItem("toolbox2").label}
>
{renderLovartSidebarIcon("toolbox2")}
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
className={`w-[248px] ${sidebarPopupBaseClass}`}
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
{getLovartSidebarItem("toolbox2").label}
</div>
<div className="space-y-1">
{toolbox2Links.map((link) => (
<button
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04]"
onClick={() => openNavLink(link.url)}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/30" />
</button>
))}
</div>
</HoverCardContent>
</HoverCard>
) : null}
{isLovartSidebarItemEnabled("settings") &&
(user?.type === "admin" || user?.type === "agent_admin") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={lovartSidebarButtonClass()}
onClick={() =>
router.push(user?.type === "agent_admin" ? "/settings/agent-workflow" : "/settings")
}
title={getLovartSidebarItem("settings").label}
>
{renderLovartSidebarIcon("settings")}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{getLovartSidebarItem("settings").label}
</TooltipContent>
</Tooltip>
) : null}
</FloatingSidebarRail>
</TooltipProvider>
</aside>
<LovartSidebar
agentEnabled={siteSettingsData?.agentEnabled !== false}
getItem={getLovartSidebarItem}
isEnabled={isLovartSidebarItemEnabled}
isProjectsOnlyView={isProjectsOnlyView}
onAllProjects={openAllProjectsInPage}
onGallery={() => router.push("/gallery")}
onHome={() => {
setIsProjectsOnlyView(false);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
onImageEditor={handleOpenImageEditor}
onNavLink={openNavLink}
onNewCanvas={openNewCanvas}
onProfile={() => toast.info("个人主页模块后续开放")}
onRoute={(url) => router.push(url)}
onTutorial={handleOpenTutorial}
productPlanningEnabled={siteSettingsData?.productPlanningEnabled !== false}
renderIcon={renderLovartSidebarIcon}
settingsHref={
user?.type === "admin"
? "/settings"
: user?.type === "agent_admin"
? "/settings/agent-workflow"
: null
}
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
toolbox1Links={toolbox1Links}
toolbox2Enabled={siteSettingsData?.toolbox2Enabled === true}
toolbox2Links={toolbox2Links}
tutorialEnabled={Boolean(siteSettingsData?.tutorialEnabled)}
tutorialVideos={tutorialVideos}
videoCanvasEnabled={siteSettingsData?.videoCanvasEnabled !== false}
/>
<main className="mx-auto max-w-[1240px] px-6 pb-20 pt-[84px]">
{!isProjectsOnlyView && (
<section className="mt-8 text-center">
{isMembershipEnabled ? (
<button
type="button"
onClick={() => setUpgradeDialogOpen(true)}
className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80 dark:bg-[#3f3013] dark:text-[#f6dfaa]"
>
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
<span className="text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
</button>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80 dark:bg-[#3f3013] dark:text-[#f6dfaa]">
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
</div>
)}
<div className="mt-8 flex items-center justify-center gap-2">
{displayLogo ? (
<img
src={displayLogo}
alt={siteName}
className={`h-10 w-10 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
<Sparkles className="h-4 w-4" />
</div>
)}
<h1 className="text-[34px] font-semibold tracking-tight dark:text-white">
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
</h1>
</div>
<p className="mt-2 text-[13px] text-black/35 dark:text-white/45">
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
</p>
<div className="mx-auto mt-6 w-full max-w-[780px] rounded-[22px] border border-black/10 bg-white px-4 pb-3 pt-4 shadow-[0_10px_24px_rgba(0,0,0,0.05)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
<Textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={composerPlaceholder}
className="min-h-[76px] resize-none border-0 bg-transparent px-1 text-[12px] shadow-none focus-visible:ring-0 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-12 w-12 overflow-hidden rounded-lg 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-xs text-white group-hover:flex"
onClick={() => removeFile(item.id)}
>
x
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between border-t border-black/8 pt-3 dark:border-white/8">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-black/10 text-black/60 hover:bg-black/5 dark:border-white/12 dark:text-white/60 dark:hover:bg-white/8">
<Upload className="h-4 w-4" />
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
handleAddFiles(event.target.files);
event.target.value = "";
}}
/>
</label>
<div className="inline-flex h-8 min-w-[130px] items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-2 text-xs text-blue-600 dark:border-sky-500/20 dark:bg-sky-500/12 dark:text-sky-200">
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white dark:border-sky-500/30 dark:bg-[#0d1117]">
{triggerComposerModel?.avatarUrl ? (
<img
src={triggerComposerModel.avatarUrl}
alt={triggerComposerModel.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
{(triggerComposerModel?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
<span className="max-w-[95px] truncate">
{triggerComposerModel?.displayName || "Agent模式"}
</span>
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<ImageParamsBar
modelName={triggerComposerModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline
providerType={triggerComposerModel?.providerType}
imageGenConfig={triggerComposerModel?.imageGenConfig || undefined}
hideImageCount
/>
</div>
</div>
<div className="flex items-center gap-1.5 pl-2">
<Button
onClick={createCanvasFromComposer}
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-8 w-8 rounded-full bg-[#9ca3af] p-0 text-white hover:bg-black dark:bg-white/16 dark:hover:bg-white/26"
>
{isSubmitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-1.5">
{visiblePromptTags.map((tag) => (
<button
key={tag.label}
type="button"
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[12px] ${
tag.highlighted
? "border-[#ffb15f] bg-[#fff4e7] text-[#ff8c1f] dark:border-[#8b6330] dark:bg-[#38270f] dark:text-[#ffbe6b]"
: "border-black/15 bg-white text-black/70 dark:border-white/10 dark:bg-[#141416] dark:text-white/70"
}`}
>
<tag.icon className="h-3 w-3" />
{tag.label}
</button>
))}
</div>
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
<div className="mt-6 w-full">
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />
</div>
) : null}
</section>
<LovartHeroComposer
composerPlaceholder={composerPlaceholder}
displayLogo={displayLogo}
files={files}
handleAddFiles={handleAddFiles}
handleImageParamsChange={handleImageParamsChange}
heroPromoActionText={heroPromoActionText}
heroPromoText={heroPromoText}
heroSubtitleText={heroSubtitleText}
heroTitleText={heroTitleText}
homeAnimationData={homeAnimationData}
imageParams={imageParams}
isMembershipEnabled={isMembershipEnabled}
isSubmitting={isSubmitting}
onSubmit={createCanvasFromComposer}
openUpgradeDialog={() => setUpgradeDialogOpen(true)}
prompt={prompt}
removeFile={removeFile}
setPrompt={setPrompt}
shouldInvertLogo={shouldInvertLogo}
siteName={siteName}
triggerComposerModel={triggerComposerModel}
visiblePromptTags={visiblePromptTags}
/>
)}
<section
ref={projectSectionRef}
className={isProjectsOnlyView ? "mt-6" : "mt-20"}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-[18px] font-semibold tracking-tight">
{isProjectsOnlyView ? "所有项目" : "最近项目"}
</h2>
{!isProjectsOnlyView && hasMoreProjects ? (
<button
type="button"
className="inline-flex items-center gap-1 text-[13px] text-black/45 hover:text-black dark:text-white/45 dark:hover:text-white"
onClick={() => setShowAllProjects((value) => !value)}
>
{showAllProjects ? "收起" : "查看更多"}
<ArrowRight className={`h-3.5 w-3.5 transition-transform ${showAllProjects ? "rotate-90" : ""}`} />
</button>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<button
type="button"
onClick={openNewCanvas}
className="cursor-pointer rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
>
<div className="flex aspect-[4/3] items-center justify-center rounded-2xl bg-[#efefef] dark:bg-[#1b1c20]">
<div className="text-center">
<div className="mb-2 text-3xl">+</div>
<div className="text-[14px]">新建项目</div>
</div>
</div>
</button>
{recentProjects.map((item) => (
<div
key={item.id}
className="group relative rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
>
<button
type="button"
onClick={() => router.push(`/canvas/${item.id}`)}
className="block w-full cursor-pointer text-left"
>
<div className="aspect-[4/3] overflow-hidden rounded-2xl bg-[#efefef] dark:bg-[#1b1c20]">
{item.thumbnail ? (
<img
src={item.thumbnail}
alt={item.title}
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.045]"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-black/35 dark:text-white/35">Untitled</div>
)}
</div>
<div className="mt-3 text-[14px] font-medium">{item.title || "Untitled"}</div>
<div className="mt-1 text-xs text-black/35 dark:text-white/35">{formatRelativeTime(item.updatedAt || item.createdAt)}</div>
</button>
<button
type="button"
onClick={() => handleDeleteProject(item.id, item.title)}
disabled={deletingProjectId === item.id}
className="absolute right-5 top-5 z-20 inline-flex h-7 w-7 items-center justify-center rounded-full border border-black/10 bg-white/90 text-black/45 shadow-sm transition hover:border-[#ffb4b4] hover:text-[#ef4444] disabled:cursor-not-allowed disabled:opacity-80 dark:border-white/10 dark:bg-[#18181c]/90 dark:text-white/45"
aria-label={`删除${item.title || "未命名画布"}`}
>
{deletingProjectId === item.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
))}
{recentProjects.length === 0 && (
<div className="rounded-[22px] border border-dashed border-black/15 bg-white p-6 text-center text-black/40 md:col-span-3 dark:border-white/12 dark:bg-[#141416] dark:text-white/40">
还没有项目,点击“新建项目”开始。
</div>
)}
</div>
</section>
<LovartProjectsSection
deletingProjectId={deletingProjectId}
hasMoreProjects={hasMoreProjects}
isProjectsOnlyView={isProjectsOnlyView}
onCreateProject={openNewCanvas}
onDeleteProject={handleDeleteProject}
onOpenProject={(projectId) => router.push(`/canvas/${projectId}`)}
projects={recentProjects}
projectSectionRef={projectSectionRef}
setShowAllProjects={setShowAllProjects}
showAllProjects={showAllProjects}
/>
{!isProjectsOnlyView && (
<section ref={featuredSectionRef} className="mt-16">
<h2 className="text-[18px] font-semibold tracking-tight">灵感发现</h2>
<div className="mt-3 flex flex-wrap gap-1.5">
{discoveryTabs.map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`rounded-xl px-3 py-1.5 text-[12px] ${
activeCategory === category
? "bg-[#ececec] text-black dark:bg-[#24242a] dark:text-white"
: "text-black/55 hover:bg-white dark:text-white/55 dark:hover:bg-[#17171b]"
}`}
>
{category}
</button>
))}
</div>
{featuredItems.length === 0 ? (
<div className="mt-5 rounded-xl border border-dashed border-black/15 bg-white p-10 text-center text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
</div>
) : (
<div className="mt-5 columns-1 gap-4 md:columns-2 xl:columns-4">
{featuredItems.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
className="group mb-4 block w-full cursor-pointer break-inside-avoid rounded-2xl border border-black/8 bg-white text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
>
<div
className="overflow-hidden rounded-t-2xl bg-[#e8e8e8] dark:bg-[#1b1c20]"
style={{ height: `${MASONRY_HEIGHTS[index % MASONRY_HEIGHTS.length]}px` }}
>
<img
src={item.thumbnailUrl || item.originalUrl}
alt={item.prompt || "灵感图"}
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.04]"
/>
</div>
<div className="flex items-center justify-between px-3 py-2 text-[12px] text-black/60 dark:text-white/55">
<div className="line-clamp-1 font-medium text-black/85 dark:text-white/85">
{item.user?.name || "创作者"}
</div>
<div className="inline-flex items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1">
<Sparkles className="h-2.5 w-2.5" />
{(1200 + index * 37).toLocaleString()}
</span>
<span className="inline-flex items-center gap-1">
<Heart className="h-2.5 w-2.5" />
{(20 + (index % 9) * 11).toLocaleString()}
</span>
</div>
</div>
</button>
))}
</div>
)}
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
{isLoadingMoreFeatured ? (
<div className="inline-flex items-center gap-2 text-sm text-black/45 dark:text-white/45">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载更多灵感...
</div>
) : null}
</div>
</section>
<LovartFeaturedGallery
activeCategory={activeCategory}
categories={discoveryTabs}
featuredItems={featuredItems}
featuredLoadMoreRef={featuredLoadMoreRef}
featuredSectionRef={featuredSectionRef}
isLoadingFeatured={isLoadingFeatured}
isLoadingMoreFeatured={isLoadingMoreFeatured}
setActiveCategory={setActiveCategory}
/>
)}
</main>

View File

@ -0,0 +1,100 @@
import { Heart, Loader2, Sparkles } from "lucide-react";
import type { Ref } from "react";
import type { FeaturedImage } from "./types";
import { MASONRY_HEIGHTS } from "./utils";
type LovartFeaturedGalleryProps = {
activeCategory: string;
categories: string[];
featuredItems: FeaturedImage[];
featuredLoadMoreRef: Ref<HTMLDivElement>;
featuredSectionRef: Ref<HTMLElement>;
isLoadingFeatured: boolean;
isLoadingMoreFeatured: boolean;
setActiveCategory: (category: string) => void;
};
export function LovartFeaturedGallery({
activeCategory,
categories,
featuredItems,
featuredLoadMoreRef,
featuredSectionRef,
isLoadingFeatured,
isLoadingMoreFeatured,
setActiveCategory,
}: LovartFeaturedGalleryProps) {
return (
<section ref={featuredSectionRef} className="mt-16">
<h2 className="text-[18px] font-semibold tracking-tight">灵感发现</h2>
<div className="mt-3 flex flex-wrap gap-1.5">
{categories.map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`rounded-xl px-3 py-1.5 text-[12px] ${
activeCategory === category
? "bg-[#ececec] text-black dark:bg-[#24242a] dark:text-white"
: "text-black/55 hover:bg-white dark:text-white/55 dark:hover:bg-[#17171b]"
}`}
>
{category}
</button>
))}
</div>
{featuredItems.length === 0 ? (
<div className="mt-5 rounded-xl border border-dashed border-black/15 bg-white p-10 text-center text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
</div>
) : (
<div className="mt-5 columns-1 gap-4 md:columns-2 xl:columns-4">
{featuredItems.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
className="group mb-4 block w-full cursor-pointer break-inside-avoid rounded-2xl border border-black/8 bg-white text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
>
<div
className="overflow-hidden rounded-t-2xl bg-[#e8e8e8] dark:bg-[#1b1c20]"
style={{ height: `${MASONRY_HEIGHTS[index % MASONRY_HEIGHTS.length]}px` }}
>
<img
src={item.thumbnailUrl || item.originalUrl}
alt={item.prompt || "灵感图"}
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.04]"
/>
</div>
<div className="flex items-center justify-between px-3 py-2 text-[12px] text-black/60 dark:text-white/55">
<div className="line-clamp-1 font-medium text-black/85 dark:text-white/85">
{item.user?.name || "创作者"}
</div>
<div className="inline-flex items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1">
<Sparkles className="h-2.5 w-2.5" />
{(1200 + index * 37).toLocaleString()}
</span>
<span className="inline-flex items-center gap-1">
<Heart className="h-2.5 w-2.5" />
{(20 + (index % 9) * 11).toLocaleString()}
</span>
</div>
</div>
</button>
))}
</div>
)}
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
{isLoadingMoreFeatured ? (
<div className="inline-flex items-center gap-2 text-sm text-black/45 dark:text-white/45">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载更多灵感...
</div>
) : null}
</div>
</section>
);
}

View File

@ -0,0 +1,217 @@
import { ArrowUp, Loader2, Sparkles, Upload } from "lucide-react";
import type { ChangeEvent } from "react";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { ModelOption, UploadFileItem } from "./types";
import type { PROMPT_TAGS } from "./utils";
type PromptTag = (typeof PROMPT_TAGS)[number];
type LovartHeroComposerProps = {
composerPlaceholder: string;
displayLogo: string;
files: UploadFileItem[];
handleAddFiles: (files: FileList | null) => void;
handleImageParamsChange: (nextParams: ImageGenerationParams) => void;
heroPromoActionText?: string | null;
heroPromoText?: string | null;
heroSubtitleText?: string | null;
heroTitleText?: string | null;
homeAnimationData?: {
enabled?: boolean;
htmlContent?: string;
fileName?: string;
};
imageParams: ImageGenerationParams;
isMembershipEnabled: boolean;
isSubmitting: boolean;
onSubmit: () => void;
openUpgradeDialog: () => void;
prompt: string;
removeFile: (id: string) => void;
setPrompt: (value: string) => void;
shouldInvertLogo: boolean;
siteName: string;
triggerComposerModel: ModelOption;
visiblePromptTags: PromptTag[];
};
export function LovartHeroComposer({
composerPlaceholder,
displayLogo,
files,
handleAddFiles,
handleImageParamsChange,
heroPromoActionText,
heroPromoText,
heroSubtitleText,
heroTitleText,
homeAnimationData,
imageParams,
isMembershipEnabled,
isSubmitting,
onSubmit,
openUpgradeDialog,
prompt,
removeFile,
setPrompt,
shouldInvertLogo,
siteName,
triggerComposerModel,
visiblePromptTags,
}: LovartHeroComposerProps) {
return (
<section className="mt-8 text-center">
{isMembershipEnabled ? (
<button
type="button"
onClick={openUpgradeDialog}
className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80 dark:bg-[#3f3013] dark:text-[#f6dfaa]"
>
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
<span className="text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
</button>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80 dark:bg-[#3f3013] dark:text-[#f6dfaa]">
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
</div>
)}
<div className="mt-8 flex items-center justify-center gap-2">
{displayLogo ? (
<img
src={displayLogo}
alt={siteName}
className={`h-10 w-10 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
<Sparkles className="h-4 w-4" />
</div>
)}
<h1 className="text-[34px] font-semibold tracking-tight dark:text-white">
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
</h1>
</div>
<p className="mt-2 text-[13px] text-black/35 dark:text-white/45">
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
</p>
<div className="mx-auto mt-6 w-full max-w-[780px] rounded-[22px] border border-black/10 bg-white px-4 pb-3 pt-4 shadow-[0_10px_24px_rgba(0,0,0,0.05)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
<Textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={composerPlaceholder}
className="min-h-[76px] resize-none border-0 bg-transparent px-1 text-[12px] shadow-none focus-visible:ring-0 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-12 w-12 overflow-hidden rounded-lg 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-xs text-white group-hover:flex"
onClick={() => removeFile(item.id)}
>
x
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between border-t border-black/8 pt-3 dark:border-white/8">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-black/10 text-black/60 hover:bg-black/5 dark:border-white/12 dark:text-white/60 dark:hover:bg-white/8">
<Upload className="h-4 w-4" />
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleAddFiles(event.target.files);
event.target.value = "";
}}
/>
</label>
<div className="inline-flex h-8 min-w-[130px] items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-2 text-xs text-blue-600 dark:border-sky-500/20 dark:bg-sky-500/12 dark:text-sky-200">
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white dark:border-sky-500/30 dark:bg-[#0d1117]">
{triggerComposerModel?.avatarUrl ? (
<img
src={triggerComposerModel.avatarUrl}
alt={triggerComposerModel.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
{(triggerComposerModel?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
<span className="max-w-[95px] truncate">
{triggerComposerModel?.displayName || "Agent模式"}
</span>
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<ImageParamsBar
modelName={triggerComposerModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline
providerType={triggerComposerModel?.providerType}
imageGenConfig={triggerComposerModel?.imageGenConfig || undefined}
hideImageCount
/>
</div>
</div>
<div className="flex items-center gap-1.5 pl-2">
<Button
onClick={onSubmit}
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-8 w-8 rounded-full bg-[#9ca3af] p-0 text-white hover:bg-black dark:bg-white/16 dark:hover:bg-white/26"
>
{isSubmitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-1.5">
{visiblePromptTags.map((tag) => (
<button
key={tag.label}
type="button"
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[12px] ${
tag.highlighted
? "border-[#ffb15f] bg-[#fff4e7] text-[#ff8c1f] dark:border-[#8b6330] dark:bg-[#38270f] dark:text-[#ffbe6b]"
: "border-black/15 bg-white text-black/70 dark:border-white/10 dark:bg-[#141416] dark:text-white/70"
}`}
>
<tag.icon className="h-3 w-3" />
{tag.label}
</button>
))}
</div>
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
<div className="mt-6 w-full">
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,122 @@
import { ArrowRight, Loader2, Trash2 } from "lucide-react";
import type { Ref } from "react";
import type { CanvasProject } from "./types";
import { formatRelativeTime } from "./utils";
type LovartProjectsSectionProps = {
deletingProjectId: string | null;
hasMoreProjects: boolean;
isProjectsOnlyView: boolean;
onCreateProject: () => void;
onDeleteProject: (projectId: string, projectTitle?: string) => void;
onOpenProject: (projectId: string) => void;
projects: CanvasProject[];
projectSectionRef: Ref<HTMLElement>;
setShowAllProjects: (updater: (value: boolean) => boolean) => void;
showAllProjects: boolean;
};
export function LovartProjectsSection({
deletingProjectId,
hasMoreProjects,
isProjectsOnlyView,
onCreateProject,
onDeleteProject,
onOpenProject,
projects,
projectSectionRef,
setShowAllProjects,
showAllProjects,
}: LovartProjectsSectionProps) {
return (
<section
ref={projectSectionRef}
className={isProjectsOnlyView ? "mt-6" : "mt-20"}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-[18px] font-semibold tracking-tight">
{isProjectsOnlyView ? "所有项目" : "最近项目"}
</h2>
{!isProjectsOnlyView && hasMoreProjects ? (
<button
type="button"
className="inline-flex items-center gap-1 text-[13px] text-black/45 hover:text-black dark:text-white/45 dark:hover:text-white"
onClick={() => setShowAllProjects((value) => !value)}
>
{showAllProjects ? "收起" : "查看更多"}
<ArrowRight
className={`h-3.5 w-3.5 transition-transform ${showAllProjects ? "rotate-90" : ""}`}
/>
</button>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<button
type="button"
onClick={onCreateProject}
className="cursor-pointer rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
>
<div className="flex aspect-[4/3] items-center justify-center rounded-2xl bg-[#efefef] dark:bg-[#1b1c20]">
<div className="text-center">
<div className="mb-2 text-3xl">+</div>
<div className="text-[14px]">新建项目</div>
</div>
</div>
</button>
{projects.map((item) => (
<div
key={item.id}
className="group relative rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
>
<button
type="button"
onClick={() => onOpenProject(item.id)}
className="block w-full cursor-pointer text-left"
>
<div className="aspect-[4/3] overflow-hidden rounded-2xl bg-[#efefef] dark:bg-[#1b1c20]">
{item.thumbnail ? (
<img
src={item.thumbnail}
alt={item.title}
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.045]"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-black/35 dark:text-white/35">
Untitled
</div>
)}
</div>
<div className="mt-3 text-[14px] font-medium">
{item.title || "Untitled"}
</div>
<div className="mt-1 text-xs text-black/35 dark:text-white/35">
{formatRelativeTime(item.updatedAt || item.createdAt)}
</div>
</button>
<button
type="button"
onClick={() => onDeleteProject(item.id, item.title)}
disabled={deletingProjectId === item.id}
className="absolute right-5 top-5 z-20 inline-flex h-7 w-7 items-center justify-center rounded-full border border-black/10 bg-white/90 text-black/45 shadow-sm transition hover:border-[#ffb4b4] hover:text-[#ef4444] disabled:cursor-not-allowed disabled:opacity-80 dark:border-white/10 dark:bg-[#18181c]/90 dark:text-white/45"
aria-label={`删除${item.title || "未命名画布"}`}
>
{deletingProjectId === item.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
))}
{projects.length === 0 && (
<div className="rounded-[22px] border border-dashed border-black/15 bg-white p-6 text-center text-black/40 md:col-span-3 dark:border-white/12 dark:bg-[#141416] dark:text-white/40">
还没有项目,点击“新建项目”开始。
</div>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,258 @@
import { ArrowRight, PlayCircle } from "lucide-react";
import type { ReactNode } from "react";
import { FloatingSidebarRail } from "@/components/home/floating-sidebar-shell";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
import type { NavLink, TutorialVideo } from "./types";
type SidebarItem = {
iconUrl?: string | null;
label: string;
};
type LovartSidebarProps = {
agentEnabled: boolean;
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
isProjectsOnlyView: boolean;
onAllProjects: () => void;
onGallery: () => void;
onHome: () => void;
onImageEditor: () => void;
onNavLink: (url: string) => void;
onNewCanvas: () => void;
onProfile: () => void;
onRoute: (url: string) => void;
onTutorial: (video: TutorialVideo) => void;
productPlanningEnabled: boolean;
renderIcon: (
key: LovartHomeSidebarItemKey,
options?: {
active?: boolean;
filled?: boolean;
sizeClassName?: string;
}
) => ReactNode;
settingsHref: string | null;
toolbox1Enabled: boolean;
toolbox1Links: NavLink[];
toolbox2Enabled: boolean;
toolbox2Links: NavLink[];
tutorialEnabled: boolean;
tutorialVideos: TutorialVideo[];
videoCanvasEnabled: boolean;
};
export function LovartSidebar({
agentEnabled,
getItem,
isEnabled,
isProjectsOnlyView,
onAllProjects,
onGallery,
onHome,
onImageEditor,
onNavLink,
onNewCanvas,
onProfile,
onRoute,
onTutorial,
productPlanningEnabled,
renderIcon,
settingsHref,
toolbox1Enabled,
toolbox1Links,
toolbox2Enabled,
toolbox2Links,
tutorialEnabled,
tutorialVideos,
videoCanvasEnabled,
}: LovartSidebarProps) {
const sidebarPopupBaseClass =
"rounded-[24px] border border-black/8 bg-white/96 p-2.5 text-[#111827] shadow-[0_24px_64px_rgba(15,23,42,0.12)] backdrop-blur-xl";
const sidebarButtonClass = (active = false) =>
[
"group flex h-10 w-10 items-center justify-center rounded-xl text-[#6b7280] transition-all duration-200 hover:bg-black/[0.04] hover:text-[#111827]",
active
? "bg-[#111827] text-white shadow-[0_10px_22px_rgba(15,23,42,0.18)]"
: "",
]
.filter(Boolean)
.join(" ");
const tooltipButton = (
key: LovartHomeSidebarItemKey,
onClick: () => void,
options?: { active?: boolean }
) =>
isEnabled(key) ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={sidebarButtonClass(options?.active)}
onClick={onClick}
title={getItem(key).label}
>
{renderIcon(key, { active: options?.active })}
</button>
</TooltipTrigger>
<TooltipContent side="right">{getItem(key).label}</TooltipContent>
</Tooltip>
) : null;
const toolbox = (
key: "toolbox1" | "toolbox2",
links: NavLink[],
enabled: boolean
) =>
isEnabled(key) && enabled && links.length > 0 ? (
<HoverCard openDelay={80} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className={sidebarButtonClass()}
title={getItem(key).label}
>
{renderIcon(key)}
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
className={`w-[248px] ${sidebarPopupBaseClass}`}
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
{getItem(key).label}
</div>
<div className="space-y-1">
{links.map((link) => (
<button
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04]"
onClick={() => onNavLink(link.url)}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/30" />
</button>
))}
</div>
</HoverCardContent>
</HoverCard>
) : null;
return (
<aside className="fixed left-5 top-[88px] z-30 hidden flex-col items-center gap-3 lg:flex">
<TooltipProvider delayDuration={120}>
{isEnabled("newProject") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onNewCanvas}
className="flex h-[56px] w-[56px] shrink-0 items-center justify-center rounded-[20px] border border-black/8 bg-white/96 shadow-[0_20px_46px_rgba(15,23,42,0.12)] backdrop-blur-xl transition-transform duration-200 hover:-translate-y-0.5 hover:bg-white"
title={getItem("newProject").label}
>
<div className="flex h-9 w-9 items-center justify-center rounded-[12px] bg-[#111827] text-white shadow-[0_10px_20px_rgba(15,23,42,0.18)]">
{renderIcon("newProject", {
filled: true,
sizeClassName: "h-4 w-4",
})}
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right">{getItem("newProject").label}</TooltipContent>
</Tooltip>
) : null}
<FloatingSidebarRail className="max-h-[calc(100vh-172px)]" tone="light">
{tooltipButton("home", onHome, { active: !isProjectsOnlyView })}
{tooltipButton("projects", onAllProjects, { active: isProjectsOnlyView })}
{tooltipButton("profile", onProfile)}
{tooltipButton("gallery", onGallery)}
{isEnabled("tutorial") && tutorialEnabled ? (
<HoverCard openDelay={80} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
className={sidebarButtonClass()}
title={getItem("tutorial").label}
>
{renderIcon("tutorial")}
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
className={`w-[300px] ${sidebarPopupBaseClass}`}
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
{getItem("tutorial").label}
</div>
<div className="max-h-[380px] space-y-1 overflow-y-auto">
{tutorialVideos.length > 0 ? (
tutorialVideos.map((video) => (
<button
key={video.id}
type="button"
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04]"
onClick={() => onTutorial(video)}
>
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04]">
{video.coverUrl ? (
<img
src={video.coverUrl}
alt={video.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<PlayCircle className="h-4 w-4 text-black/35" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-black/85">
{video.title}
</div>
{video.description ? (
<div className="truncate text-[11px] text-black/42">
{video.description}
</div>
) : null}
</div>
</button>
))
) : (
<div className="px-2 py-3 text-[12px] text-black/42">暂无教程视频</div>
)}
</div>
</HoverCardContent>
</HoverCard>
) : null}
{tooltipButton("imageEditor", onImageEditor)}
{tooltipButton("aiCad", () => onRoute("/ai-cad"))}
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
{tooltipButton("flowBoard", () => onRoute("/flow-board"))}
{productPlanningEnabled
? tooltipButton("productPlanning", () => onRoute("/product-planning"))
: null}
{toolbox("toolbox1", toolbox1Links, toolbox1Enabled)}
{toolbox("toolbox2", toolbox2Links, toolbox2Enabled)}
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
</FloatingSidebarRail>
</TooltipProvider>
</aside>
);
}

View File

@ -0,0 +1,161 @@
import type { ImageGenerationParams } from "@/components/image-params-bar";
import type { MembershipConfig } from "@/lib/membership/config";
export type HomeUser = {
id: string;
email?: string | null;
name?: string | null;
image?: string | null;
type?: string | null;
};
export type CanvasProject = {
id: string;
title: string;
thumbnail?: string | null;
createdAt?: string;
updatedAt?: string;
};
export type FeaturedImage = {
id: string;
thumbnailUrl: string;
originalUrl: string;
prompt?: string | null;
featuredCategory?: string | null;
user?: {
name?: string;
department?: string;
};
};
export type FeaturedResponse = {
images: FeaturedImage[];
pagination?: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
};
export type ModelOption = {
id: string;
modelId: string;
displayName: string;
avatarUrl?: string | null;
providerType: string;
supportsImageGen?: boolean;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
} | null;
};
export type NavLink = {
id: string;
name: string;
url: string;
order: number;
isEnabled: boolean;
toolboxGroup: number;
};
export type TutorialVideo = {
id: string;
title: string;
videoUrl: string;
coverUrl?: string | null;
description?: string | null;
order: number;
isEnabled: boolean;
};
export type PublicAuthSettings = {
isDingTalkEnabled?: boolean;
isGoogleEnabled?: boolean;
isGoogleConfigured?: boolean;
isRegistrationEnabled?: boolean;
organizations?: Array<{ name?: string } | string>;
dingTalkOrganizations?: Array<{ name?: string } | string>;
};
export type ContactConfig = {
phone?: string;
wechatQrUrl?: string;
};
export type MembershipSummaryResponse = {
config: MembershipConfig;
currentPlanId: string;
currentPlan: {
id: string;
name: string;
monthlyPrice: number;
annualPrice: number;
monthlyCredits: number;
dailyFreeCredits: number;
features: string[];
badge?: string;
highlighted?: boolean;
};
summary: {
currentCredits: number;
dailyFreeCredits: number;
usedThisMonth: number;
monthlyLimit: number | null;
remainingThisMonth: number | null;
totalImages: number;
};
};
export type PointsPackage = {
id: string;
name: string;
points: number;
price: number;
currency: string;
isEnabled: boolean;
};
export type AccountCenterResponse = {
profile: {
name: string;
email: string;
currentPlanName: string;
currentPlanId: string;
availablePoints: number;
monthlyCredits: number;
dailyFreeCredits: number;
};
usageRecords: Array<{
id: string;
detail: string;
status: "consumed" | "earned";
amount: number;
createdAt: string;
}>;
billingRecords: Array<{
id: string;
date: string;
category: string;
amount: number;
status: "completed" | "pending" | "failed";
invoice: string;
}>;
};
export type UploadFileItem = {
id: string;
file: File;
previewUrl: string;
};
export type ComposerImageParams = ImageGenerationParams;

View File

@ -0,0 +1,152 @@
import {
Box,
BarChart3,
Briefcase,
Clapperboard,
Edit,
Folder,
Home,
ImageIcon,
Monitor,
Moon,
Palette,
PlayCircle,
Plus,
Settings,
Sparkles,
Star,
Sun,
User,
Wand2,
Workflow,
type LucideIcon,
} from "lucide-react";
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
import type { HomeUser } from "./types";
export type ThemeMode = "system" | "light" | "dark";
export const THEME_MODE_OPTIONS: Array<{
value: ThemeMode;
label: string;
description: string;
icon: LucideIcon;
}> = [
{
value: "system",
label: "跟随系统",
description: "随浏览器/系统深浅色自动切换",
icon: Monitor,
},
{
value: "light",
label: "浅色模式",
description: "始终使用明亮界面",
icon: Sun,
},
{
value: "dark",
label: "深色模式",
description: "始终使用暗色界面",
icon: Moon,
},
];
export const DISCOVERY_DEFAULT_TABS = [
"品牌设计",
"海报与广告",
"插画",
"UI设计",
"角色设计",
"影片与分镜",
"产品设计",
"建筑设计",
];
export const PROMPT_TAGS = [
{ key: "nanoBananaPro", label: "Nano Banana Pro", icon: Sparkles, highlighted: true },
{ key: "design", label: "Design", icon: ImageIcon, highlighted: false },
{ key: "branding", label: "Branding", icon: Star, highlighted: false },
{ key: "illustration", label: "Illustration", icon: Palette, highlighted: false },
{ key: "ecommerce", label: "E-Commerce", icon: Monitor, highlighted: false },
{ key: "video", label: "Video", icon: Clapperboard, highlighted: false },
] as const;
export const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
LovartHomeSidebarItemKey,
LucideIcon
> = {
newProject: Plus,
home: Home,
projects: Folder,
profile: User,
gallery: ImageIcon,
tutorial: PlayCircle,
imageEditor: Edit,
aiCad: Box,
agent: Wand2,
videoCanvas: Clapperboard,
flowBoard: Workflow,
productPlanning: BarChart3,
toolbox1: Briefcase,
toolbox2: Briefcase,
settings: Settings,
};
export type LovartPromptTagKey = (typeof PROMPT_TAGS)[number]["key"];
export type LovartPromptTagVisibility = Record<LovartPromptTagKey, boolean>;
export const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
export const FEATURED_PAGE_SIZE = 24;
export const COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
export function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
const trimmed = text.trim();
if (!trimmed) return "";
const sanitizedPrefix = fixedPrefix.trim();
if (sanitizedPrefix && trimmed.startsWith(sanitizedPrefix)) {
return trimmed.slice(sanitizedPrefix.length).trimStart();
}
const legacyPrefixRemoved = trimmed
.replace(/^让\s*buyi[-_ ]?ai\s*/i, "")
.replace(/^让\s*lovart\s*/i, "")
.trimStart();
if (legacyPrefixRemoved !== trimmed) {
return legacyPrefixRemoved;
}
return trimmed;
}
export function getUserInitials(user: HomeUser | null) {
const source = (user?.name || user?.email || "mu").trim();
if (!source) return "mu";
return source.slice(0, 2).toLowerCase();
}
export function formatRelativeTime(value?: string) {
if (!value) return "更新于今天";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "更新于今天";
const diffMs = Date.now() - date.getTime();
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diffMs < hour) return `更新于 ${Math.max(1, Math.round(diffMs / minute))} 分钟前`;
if (diffMs < day) return `更新于 ${Math.round(diffMs / hour)} 小时前`;
return `更新于 ${Math.round(diffMs / day)} 天前`;
}
export function formatPrice(currency: string, value: number) {
if (value <= 0) return "免费";
return `${currency}${value}`;
}
export function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { hour12: false });
}