diff --git a/app/api/public/lovart/route.ts b/app/api/public/lovart/route.ts index 159d58d..b3c058e 100644 --- a/app/api/public/lovart/route.ts +++ b/app/api/public/lovart/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { + getPublicChangelogEvents, getEnabledAiModelsWithProvider, getNavLinks, getPublicFeaturedImages, @@ -67,6 +68,41 @@ export async function GET(request: NextRequest) { return response; } + if (view === "blog") { + const featuredImages = await getPublicFeaturedImages(8); + + const response = NextResponse.json({ + view, + settings, + blog: { + videos, + featuredImages, + }, + }); + + response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300"); + return response; + } + + if (view === "changelog") { + const [events, ranking] = await Promise.all([ + getPublicChangelogEvents(18), + settings.rankingEnabled ? getPublicFeaturedRanking(6) : Promise.resolve([]), + ]); + + const response = NextResponse.json({ + view, + settings, + changelog: { + events, + ranking, + }, + }); + + response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300"); + return response; + } + const [featuredImages, ranking] = await Promise.all([ getPublicFeaturedImages(12), settings.rankingEnabled ? getPublicFeaturedRanking(5) : Promise.resolve([]), diff --git a/app/lovart/blog/page.tsx b/app/lovart/blog/page.tsx new file mode 100644 index 0000000..a83c753 --- /dev/null +++ b/app/lovart/blog/page.tsx @@ -0,0 +1,148 @@ +import Link from "next/link"; +import { BookOpen, Calendar, PlayCircle } from "lucide-react"; +import { MarketingShell } from "@/components/lovart/marketing-shell"; +import { Reveal } from "@/components/lovart/reveal"; +import { + getNavLinks, + getPublicFeaturedImages, + getSiteSettings, + getTutorialVideos, +} from "@/lib/db/queries"; + +function formatDate(value: Date | null | undefined) { + if (!value) { + return "最近更新"; + } + + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(value)); +} + +export default async function LovartBlogPage() { + const [settings, navLinks, videos, featuredImages] = await Promise.all([ + getSiteSettings(), + getNavLinks(), + getTutorialVideos(), + getPublicFeaturedImages(8), + ]); + + const [headline, ...restVideos] = videos; + + return ( + + +
+

+ + Design Blog +

+

Lovart 博客

+

+ 这里聚合后台教程与案例内容,可作为对外 Blog 页持续运营更新。 +

+
+
+ + +
+
+
+ + {formatDate(headline?.updatedAt || headline?.createdAt)} +
+

{headline?.title || "等待发布首篇内容"}

+

+ {headline?.description || "可在后台维护教程标题、描述与链接,自动同步到博客页。"} +

+ {headline ? ( + + + 阅读/观看 + + ) : null} +
+ +
+

灵感墙

+ {featuredImages.length > 0 ? ( +
+ {featuredImages.slice(0, 6).map((image) => ( + {image.prompt + ))} +
+ ) : ( +

暂无灵感素材

+ )} +
+
+
+ + +
+

全部文章

+
+ {restVideos.length > 0 ? ( + restVideos.map((video) => ( +
+

{formatDate(video.updatedAt || video.createdAt)}

+

{video.title}

+

{video.description || "暂无描述"}

+ + + 查看详情 + +
+ )) + ) : ( +
+ 暂无更多文章内容。 +
+ )} +
+
+
+ + +
+

继续联调

+
+ + 查看更新日志 + + + 后台更新内容 + +
+
+
+
+ ); +} diff --git a/app/lovart/changelog/page.tsx b/app/lovart/changelog/page.tsx new file mode 100644 index 0000000..7888bc8 --- /dev/null +++ b/app/lovart/changelog/page.tsx @@ -0,0 +1,152 @@ +import Link from "next/link"; +import { Calendar, WandSparkles } from "lucide-react"; +import { MarketingShell } from "@/components/lovart/marketing-shell"; +import { Reveal } from "@/components/lovart/reveal"; +import { + getNavLinks, + getPublicChangelogEvents, + getPublicFeaturedRanking, + getSiteSettings, +} from "@/lib/db/queries"; + +function formatDate(value: Date | null | undefined) { + if (!value) { + return "最近更新"; + } + + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(value)); +} + +export default async function LovartChangelogPage() { + const [settings, navLinks, events, ranking] = await Promise.all([ + getSiteSettings(), + getNavLinks(), + getPublicChangelogEvents(18), + getPublicFeaturedRanking(6), + ]); + + const tutorialCount = events.filter((event) => event.kind === "tutorial").length; + const featuredCount = events.filter((event) => event.kind === "featured").length; + + return ( + + +
+

+ + Product Changelog +

+

更新日志

+

+ 自动聚合教程发布与精选作品上新,形成可追踪的产品更新时间线。 +

+
+
+ + +
+
+

近期更新时间线

+ {events.length > 0 ? ( +
+ {events.map((event) => ( +
+
+ + + {formatDate(event.occurredAt)} + + + {event.kind} + +
+

{event.title}

+

{event.summary}

+
+ {event.detail} + {event.href ? ( + + 查看 + + ) : null} +
+
+ ))} +
+ ) : ( +

暂无更新记录

+ )} +
+ +
+

精选作者榜

+
+ {ranking.length > 0 ? ( + ranking.map((item, index) => ( +
+ + {index + 1}. {item.userName || item.userEmail?.split("@")[0] || "未知用户"} + + {item.featuredCount} +
+ )) + ) : ( +

暂无榜单数据

+ )} +
+ +
+

说明

+

+ 该榜单来自精选图库实时统计,可用于运营激励展示。 +

+
+
+
+
+ + +
+

下一步

+
+ + 继续接支付与订阅 + + + 查看新闻中心 + + + 去后台运营配置 + +
+
+
+
+ ); +} diff --git a/app/lovart/page.tsx b/app/lovart/page.tsx index 172ee51..f69cb19 100644 --- a/app/lovart/page.tsx +++ b/app/lovart/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { ArrowRight, Bot, Crown, Film, Layers3, Sparkles, WandSparkles } from "lucide-react"; import type { ReactNode } from "react"; +import { HoverStack } from "@/components/lovart/hover-stack"; import { MarketingShell } from "@/components/lovart/marketing-shell"; import { Reveal } from "@/components/lovart/reveal"; import { @@ -109,28 +110,14 @@ export default async function LovartHomePage() { -
- {featuredImages.slice(0, 4).map((image, index) => ( -
- {image.prompt -
-

{image.prompt || "AI 品牌视觉草图"}

-

{image.modelId || "AI Model"}

-
-
- ))} -
+ ({ + id: image.id, + imageUrl: image.thumbnailUrl || image.originalUrl, + modelId: image.modelId || "AI Model", + prompt: image.prompt || "AI 品牌视觉草图", + }))} + /> diff --git a/components/lovart/hover-stack.tsx b/components/lovart/hover-stack.tsx new file mode 100644 index 0000000..55b6d13 --- /dev/null +++ b/components/lovart/hover-stack.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { motion, useMotionValue, useSpring, useTransform } from "framer-motion"; + +type StackItem = { + id: string; + imageUrl: string; + prompt: string; + modelId: string; +}; + +export function HoverStack({ items }: { items: StackItem[] }) { + const offsetX = useMotionValue(0); + const offsetY = useMotionValue(0); + + const smoothX = useSpring(offsetX, { stiffness: 180, damping: 20, mass: 0.5 }); + const smoothY = useSpring(offsetY, { stiffness: 180, damping: 20, mass: 0.5 }); + + const rotateY = useTransform(smoothX, [-160, 160], [-8, 8]); + const rotateX = useTransform(smoothY, [-120, 120], [7, -7]); + + const cards = items.slice(0, 3); + + if (cards.length === 0) { + return ( +
+ 暂无可展示样例 +
+ ); + } + + return ( + { + offsetX.set(0); + offsetY.set(0); + }} + onMouseMove={(event) => { + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - (rect.left + rect.width / 2); + const y = event.clientY - (rect.top + rect.height / 2); + offsetX.set(x); + offsetY.set(y); + }} + style={{ rotateX, rotateY, transformStyle: "preserve-3d" }} + > + {cards.map((item, index) => { + const offset = index * 18; + + return ( + + {item.prompt} +
+

{item.prompt}

+

{item.modelId}

+
+
+ ); + })} +
+ ); +} diff --git a/components/lovart/marketing-shell.tsx b/components/lovart/marketing-shell.tsx index 53d64de..50fbcc2 100644 --- a/components/lovart/marketing-shell.tsx +++ b/components/lovart/marketing-shell.tsx @@ -17,6 +17,8 @@ const mainTabs = [ { key: "home", label: "首页", href: "/lovart" }, { key: "pricing", label: "定价", href: "/lovart/pricing" }, { key: "news", label: "新闻", href: "/lovart/news" }, + { key: "blog", label: "博客", href: "/lovart/blog" }, + { key: "changelog", label: "更新日志", href: "/lovart/changelog" }, ] as const; const socialLinks = [ @@ -37,6 +39,10 @@ function resolveDocsLink(navLinks: NavLinkItem[], keywords: string[]) { ); } +function isExternal(url: string) { + return /^https?:\/\//i.test(url); +} + export function MarketingShell({ siteName, siteLogo, @@ -49,14 +55,14 @@ export function MarketingShell({ siteName: string; siteLogo?: string | null; navLinks: NavLinkItem[]; - active: "home" | "pricing" | "news"; + active: "home" | "pricing" | "news" | "blog" | "changelog"; bannerText?: string | null; stats?: StatItem[]; children: ReactNode; }) { const docsUrl = resolveDocsLink(navLinks, ["文档", "docs"]); - const blogUrl = resolveDocsLink(navLinks, ["博客", "blog"]); - const changelogUrl = resolveDocsLink(navLinks, ["更新", "changelog", "release"]); + const blogUrl = resolveDocsLink(navLinks, ["博客", "blog"]) || "/lovart/blog"; + const changelogUrl = resolveDocsLink(navLinks, ["更新", "changelog", "release"]) || "/lovart/changelog"; return (
@@ -85,29 +91,52 @@ export function MarketingShell({