refactor: split lovart home into focused sections
This commit is contained in:
parent
104187e2fe
commit
501f5cf0d8
@ -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>
|
||||
|
||||
|
||||
100
components/home/lovart/lovart-featured-gallery.tsx
Normal file
100
components/home/lovart/lovart-featured-gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
217
components/home/lovart/lovart-hero-composer.tsx
Normal file
217
components/home/lovart/lovart-hero-composer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
components/home/lovart/lovart-projects-section.tsx
Normal file
122
components/home/lovart/lovart-projects-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
components/home/lovart/lovart-sidebar.tsx
Normal file
258
components/home/lovart/lovart-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
components/home/lovart/types.ts
Normal file
161
components/home/lovart/types.ts
Normal 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;
|
||||
152
components/home/lovart/utils.ts
Normal file
152
components/home/lovart/utils.ts
Normal 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 });
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user