feat: extend lovart rebuild with blog and changelog experiences
This commit is contained in:
parent
d089d3a37d
commit
f6b7758ec9
@ -1,5 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
|
getPublicChangelogEvents,
|
||||||
getEnabledAiModelsWithProvider,
|
getEnabledAiModelsWithProvider,
|
||||||
getNavLinks,
|
getNavLinks,
|
||||||
getPublicFeaturedImages,
|
getPublicFeaturedImages,
|
||||||
@ -67,6 +68,41 @@ export async function GET(request: NextRequest) {
|
|||||||
return response;
|
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([
|
const [featuredImages, ranking] = await Promise.all([
|
||||||
getPublicFeaturedImages(12),
|
getPublicFeaturedImages(12),
|
||||||
settings.rankingEnabled ? getPublicFeaturedRanking(5) : Promise.resolve([]),
|
settings.rankingEnabled ? getPublicFeaturedRanking(5) : Promise.resolve([]),
|
||||||
|
|||||||
148
app/lovart/blog/page.tsx
Normal file
148
app/lovart/blog/page.tsx
Normal file
@ -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 (
|
||||||
|
<MarketingShell
|
||||||
|
active="blog"
|
||||||
|
bannerText={settings.bannerEnabled ? settings.bannerText : null}
|
||||||
|
navLinks={navLinks}
|
||||||
|
siteLogo={settings.siteLogo || settings.siteLogoDark}
|
||||||
|
siteName={settings.siteName || "AI Chatbot"}
|
||||||
|
stats={[
|
||||||
|
{ label: "文章/教程", value: String(videos.length) },
|
||||||
|
{ label: "精选灵感", value: String(featuredImages.length) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Reveal>
|
||||||
|
<section className="rounded-3xl border border-white/15 bg-white/[0.04] p-6 sm:p-10">
|
||||||
|
<p className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-zinc-200">
|
||||||
|
<BookOpen className="h-3.5 w-3.5 text-cyan-300" />
|
||||||
|
Design Blog
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 text-3xl font-semibold text-white sm:text-5xl">Lovart 博客</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-sm text-zinc-300 sm:text-base">
|
||||||
|
这里聚合后台教程与案例内容,可作为对外 Blog 页持续运营更新。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.06}>
|
||||||
|
<section className="mt-8 grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||||
|
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
|
||||||
|
<div className="mb-3 inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.03] px-2.5 py-1 text-xs text-zinc-300">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{formatDate(headline?.updatedAt || headline?.createdAt)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold text-white">{headline?.title || "等待发布首篇内容"}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-zinc-300">
|
||||||
|
{headline?.description || "可在后台维护教程标题、描述与链接,自动同步到博客页。"}
|
||||||
|
</p>
|
||||||
|
{headline ? (
|
||||||
|
<a
|
||||||
|
href={headline.videoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-5 inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
<PlayCircle className="h-4 w-4" />
|
||||||
|
阅读/观看
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
|
||||||
|
<h3 className="mb-3 text-lg font-semibold text-white">灵感墙</h3>
|
||||||
|
{featuredImages.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{featuredImages.slice(0, 6).map((image) => (
|
||||||
|
<img
|
||||||
|
key={image.id}
|
||||||
|
src={image.thumbnailUrl || image.originalUrl}
|
||||||
|
alt={image.prompt || "featured"}
|
||||||
|
className="h-20 w-full rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-300">暂无灵感素材</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.12}>
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-white">全部文章</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{restVideos.length > 0 ? (
|
||||||
|
restVideos.map((video) => (
|
||||||
|
<article key={video.id} className="rounded-2xl border border-white/15 bg-black/30 p-4">
|
||||||
|
<p className="text-xs text-zinc-400">{formatDate(video.updatedAt || video.createdAt)}</p>
|
||||||
|
<p className="mt-2 line-clamp-2 text-base font-medium text-zinc-100">{video.title}</p>
|
||||||
|
<p className="mt-2 line-clamp-3 text-sm text-zinc-300">{video.description || "暂无描述"}</p>
|
||||||
|
<a
|
||||||
|
href={video.videoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 text-sm text-cyan-300 hover:text-cyan-200"
|
||||||
|
>
|
||||||
|
<PlayCircle className="h-4 w-4" />
|
||||||
|
查看详情
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full rounded-2xl border border-dashed border-white/20 bg-white/[0.03] p-8 text-sm text-zinc-300">
|
||||||
|
暂无更多文章内容。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.18}>
|
||||||
|
<section className="mt-10 rounded-2xl border border-white/15 bg-black/25 p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-white">继续联调</h2>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3 text-sm text-zinc-300">
|
||||||
|
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/lovart/changelog">
|
||||||
|
查看更新日志
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/admin/settings">
|
||||||
|
后台更新内容
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
</MarketingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
app/lovart/changelog/page.tsx
Normal file
152
app/lovart/changelog/page.tsx
Normal file
@ -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 (
|
||||||
|
<MarketingShell
|
||||||
|
active="changelog"
|
||||||
|
bannerText={settings.bannerEnabled ? settings.bannerText : null}
|
||||||
|
navLinks={navLinks}
|
||||||
|
siteLogo={settings.siteLogo || settings.siteLogoDark}
|
||||||
|
siteName={settings.siteName || "AI Chatbot"}
|
||||||
|
stats={[
|
||||||
|
{ label: "更新事件", value: String(events.length) },
|
||||||
|
{ label: "教程更新", value: String(tutorialCount) },
|
||||||
|
{ label: "精选更新", value: String(featuredCount) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Reveal>
|
||||||
|
<section className="rounded-3xl border border-white/15 bg-white/[0.04] p-6 sm:p-10">
|
||||||
|
<p className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-zinc-200">
|
||||||
|
<WandSparkles className="h-3.5 w-3.5 text-cyan-300" />
|
||||||
|
Product Changelog
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 text-3xl font-semibold text-white sm:text-5xl">更新日志</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-sm text-zinc-300 sm:text-base">
|
||||||
|
自动聚合教程发布与精选作品上新,形成可追踪的产品更新时间线。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.07}>
|
||||||
|
<section className="mt-8 grid gap-4 lg:grid-cols-[1.25fr_1fr]">
|
||||||
|
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-white">近期更新时间线</h2>
|
||||||
|
{events.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-[11px] text-zinc-400">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatDate(event.occurredAt)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/15 px-2 py-0.5 text-[10px] uppercase tracking-wide text-zinc-300">
|
||||||
|
{event.kind}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 text-sm font-medium text-zinc-100">{event.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-zinc-300">{event.summary}</p>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="line-clamp-1 text-[11px] text-zinc-400">{event.detail}</span>
|
||||||
|
{event.href ? (
|
||||||
|
<a
|
||||||
|
href={event.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[11px] text-cyan-300 hover:text-cyan-200"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-300">暂无更新记录</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-white">精选作者榜</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{ranking.length > 0 ? (
|
||||||
|
ranking.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.userId}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-zinc-200">
|
||||||
|
{index + 1}. {item.userName || item.userEmail?.split("@")[0] || "未知用户"}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-amber-200">{item.featuredCount}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-300">暂无榜单数据</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-zinc-300">
|
||||||
|
<p className="font-medium text-zinc-100">说明</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
该榜单来自精选图库实时统计,可用于运营激励展示。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.14}>
|
||||||
|
<section className="mt-8 rounded-2xl border border-white/15 bg-black/25 p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-white">下一步</h2>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3 text-sm text-zinc-300">
|
||||||
|
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/lovart/pricing">
|
||||||
|
继续接支付与订阅
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/lovart/news">
|
||||||
|
查看新闻中心
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/admin/settings">
|
||||||
|
去后台运营配置
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
</MarketingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight, Bot, Crown, Film, Layers3, Sparkles, WandSparkles } from "lucide-react";
|
import { ArrowRight, Bot, Crown, Film, Layers3, Sparkles, WandSparkles } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { HoverStack } from "@/components/lovart/hover-stack";
|
||||||
import { MarketingShell } from "@/components/lovart/marketing-shell";
|
import { MarketingShell } from "@/components/lovart/marketing-shell";
|
||||||
import { Reveal } from "@/components/lovart/reveal";
|
import { Reveal } from "@/components/lovart/reveal";
|
||||||
import {
|
import {
|
||||||
@ -109,28 +110,14 @@ export default async function LovartHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<HoverStack
|
||||||
{featuredImages.slice(0, 4).map((image, index) => (
|
items={featuredImages.slice(0, 6).map((image) => ({
|
||||||
<article
|
id: image.id,
|
||||||
key={image.id}
|
imageUrl: image.thumbnailUrl || image.originalUrl,
|
||||||
className={`group overflow-hidden rounded-2xl border border-white/15 bg-black/30 ${
|
modelId: image.modelId || "AI Model",
|
||||||
index === 0 ? "col-span-2" : ""
|
prompt: image.prompt || "AI 品牌视觉草图",
|
||||||
}`}
|
}))}
|
||||||
>
|
/>
|
||||||
<img
|
|
||||||
alt={image.prompt || "featured image"}
|
|
||||||
className={`w-full object-cover transition duration-300 group-hover:scale-105 ${
|
|
||||||
index === 0 ? "h-44" : "h-32"
|
|
||||||
}`}
|
|
||||||
src={image.thumbnailUrl || image.originalUrl}
|
|
||||||
/>
|
|
||||||
<div className="space-y-1 p-3">
|
|
||||||
<p className="line-clamp-2 text-xs text-zinc-200">{image.prompt || "AI 品牌视觉草图"}</p>
|
|
||||||
<p className="text-[11px] text-zinc-400">{image.modelId || "AI Model"}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
|
|||||||
77
components/lovart/hover-stack.tsx
Normal file
77
components/lovart/hover-stack.tsx
Normal file
@ -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 (
|
||||||
|
<div className="rounded-2xl border border-white/15 bg-black/30 p-6 text-sm text-zinc-300">
|
||||||
|
暂无可展示样例
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="relative h-[290px] w-full [perspective:1200px]"
|
||||||
|
onMouseLeave={() => {
|
||||||
|
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 (
|
||||||
|
<motion.article
|
||||||
|
key={item.id}
|
||||||
|
animate={{ y: [offset, offset - 7, offset], scale: [1, 1.01, 1] }}
|
||||||
|
className="absolute left-0 right-0 mx-auto w-[92%] overflow-hidden rounded-2xl border border-white/15 bg-black/35 shadow-[0_20px_60px_-24px_rgba(14,165,233,0.55)]"
|
||||||
|
style={{
|
||||||
|
zIndex: cards.length - index,
|
||||||
|
transform: `translateZ(${(cards.length - index) * 28}px)`,
|
||||||
|
top: `${index * 24}px`,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 4 + index,
|
||||||
|
ease: "easeInOut",
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img alt={item.prompt} className="h-36 w-full object-cover" src={item.imageUrl} />
|
||||||
|
<div className="space-y-1 p-3">
|
||||||
|
<p className="line-clamp-2 text-xs text-zinc-200">{item.prompt}</p>
|
||||||
|
<p className="text-[11px] text-zinc-400">{item.modelId}</p>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,6 +17,8 @@ const mainTabs = [
|
|||||||
{ key: "home", label: "首页", href: "/lovart" },
|
{ key: "home", label: "首页", href: "/lovart" },
|
||||||
{ key: "pricing", label: "定价", href: "/lovart/pricing" },
|
{ key: "pricing", label: "定价", href: "/lovart/pricing" },
|
||||||
{ key: "news", label: "新闻", href: "/lovart/news" },
|
{ key: "news", label: "新闻", href: "/lovart/news" },
|
||||||
|
{ key: "blog", label: "博客", href: "/lovart/blog" },
|
||||||
|
{ key: "changelog", label: "更新日志", href: "/lovart/changelog" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
@ -37,6 +39,10 @@ function resolveDocsLink(navLinks: NavLinkItem[], keywords: string[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExternal(url: string) {
|
||||||
|
return /^https?:\/\//i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
export function MarketingShell({
|
export function MarketingShell({
|
||||||
siteName,
|
siteName,
|
||||||
siteLogo,
|
siteLogo,
|
||||||
@ -49,14 +55,14 @@ export function MarketingShell({
|
|||||||
siteName: string;
|
siteName: string;
|
||||||
siteLogo?: string | null;
|
siteLogo?: string | null;
|
||||||
navLinks: NavLinkItem[];
|
navLinks: NavLinkItem[];
|
||||||
active: "home" | "pricing" | "news";
|
active: "home" | "pricing" | "news" | "blog" | "changelog";
|
||||||
bannerText?: string | null;
|
bannerText?: string | null;
|
||||||
stats?: StatItem[];
|
stats?: StatItem[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const docsUrl = resolveDocsLink(navLinks, ["文档", "docs"]);
|
const docsUrl = resolveDocsLink(navLinks, ["文档", "docs"]);
|
||||||
const blogUrl = resolveDocsLink(navLinks, ["博客", "blog"]);
|
const blogUrl = resolveDocsLink(navLinks, ["博客", "blog"]) || "/lovart/blog";
|
||||||
const changelogUrl = resolveDocsLink(navLinks, ["更新", "changelog", "release"]);
|
const changelogUrl = resolveDocsLink(navLinks, ["更新", "changelog", "release"]) || "/lovart/changelog";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#050814] text-zinc-100">
|
<div className="min-h-dvh bg-[#050814] text-zinc-100">
|
||||||
@ -85,29 +91,52 @@ export function MarketingShell({
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-5 text-sm lg:flex">
|
<nav className="hidden items-center gap-5 text-sm lg:flex">
|
||||||
{mainTabs.map((tab) => (
|
{mainTabs.map((tab) => {
|
||||||
<Link
|
if (tab.key === "blog" && isExternal(blogUrl)) {
|
||||||
key={tab.key}
|
return (
|
||||||
href={tab.href}
|
<a
|
||||||
className={
|
key={tab.key}
|
||||||
active === tab.key
|
className={active === tab.key ? "text-white" : "text-zinc-300 transition hover:text-white"}
|
||||||
? "text-white"
|
href={blogUrl}
|
||||||
: "text-zinc-300 transition hover:text-white"
|
rel="noreferrer"
|
||||||
}
|
target="_blank"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Link>
|
</a>
|
||||||
))}
|
);
|
||||||
{blogUrl ? (
|
}
|
||||||
<a className="text-zinc-300 transition hover:text-white" href={blogUrl} rel="noreferrer" target="_blank">
|
|
||||||
博客
|
if (tab.key === "changelog" && isExternal(changelogUrl)) {
|
||||||
</a>
|
return (
|
||||||
) : null}
|
<a
|
||||||
{changelogUrl ? (
|
key={tab.key}
|
||||||
<a className="text-zinc-300 transition hover:text-white" href={changelogUrl} rel="noreferrer" target="_blank">
|
className={active === tab.key ? "text-white" : "text-zinc-300 transition hover:text-white"}
|
||||||
更新日志
|
href={changelogUrl}
|
||||||
</a>
|
rel="noreferrer"
|
||||||
) : null}
|
target="_blank"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const href =
|
||||||
|
tab.key === "blog"
|
||||||
|
? blogUrl
|
||||||
|
: tab.key === "changelog"
|
||||||
|
? changelogUrl
|
||||||
|
: tab.href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.key}
|
||||||
|
href={href}
|
||||||
|
className={active === tab.key ? "text-white" : "text-zinc-300 transition hover:text-white"}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{docsUrl ? (
|
{docsUrl ? (
|
||||||
<a className="inline-flex items-center gap-1 text-zinc-300 transition hover:text-white" href={docsUrl} rel="noreferrer" target="_blank">
|
<a className="inline-flex items-center gap-1 text-zinc-300 transition hover:text-white" href={docsUrl} rel="noreferrer" target="_blank">
|
||||||
文档
|
文档
|
||||||
@ -152,7 +181,7 @@ export function MarketingShell({
|
|||||||
<main className="relative z-10 mx-auto w-full max-w-6xl px-4 pb-20 pt-8 sm:px-6">{children}</main>
|
<main className="relative z-10 mx-auto w-full max-w-6xl px-4 pb-20 pt-8 sm:px-6">{children}</main>
|
||||||
|
|
||||||
<div className="fixed bottom-3 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/15 bg-[#0b1020]/80 p-1 backdrop-blur sm:hidden">
|
<div className="fixed bottom-3 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/15 bg-[#0b1020]/80 p-1 backdrop-blur sm:hidden">
|
||||||
{mainTabs.map((tab) => (
|
{mainTabs.slice(0, 3).map((tab) => (
|
||||||
<Link
|
<Link
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
className={`rounded-full px-3 py-1 text-xs ${
|
className={`rounded-full px-3 py-1 text-xs ${
|
||||||
|
|||||||
@ -14,19 +14,24 @@
|
|||||||
| `/lovart` | `getPublicFeaturedRanking` | DB 直连(RSC) | 已接入 |
|
| `/lovart` | `getPublicFeaturedRanking` | DB 直连(RSC) | 已接入 |
|
||||||
| `/lovart/pricing` | 模型、教程、精选样例 | DB 直连(RSC) | 已接入 |
|
| `/lovart/pricing` | 模型、教程、精选样例 | DB 直连(RSC) | 已接入 |
|
||||||
| `/lovart/news` | 教程流 + 精选流 + 榜单 | DB 直连(RSC) | 已接入 |
|
| `/lovart/news` | 教程流 + 精选流 + 榜单 | DB 直连(RSC) | 已接入 |
|
||||||
|
| `/lovart/blog` | 教程流 + 灵感墙 | DB 直连(RSC) | 已接入 |
|
||||||
|
| `/lovart/changelog` | 教程更新 + 精选更新时间线 | DB 直连(RSC) | 已接入 |
|
||||||
| `/lovart*` 公开访问 | `proxy.ts` 放行 | 中间件路由白名单 | 已接入 |
|
| `/lovart*` 公开访问 | `proxy.ts` 放行 | 中间件路由白名单 | 已接入 |
|
||||||
| `/api/public/lovart?view=home` | 首页聚合数据 | Public API | 已接入 |
|
| `/api/public/lovart?view=home` | 首页聚合数据 | Public API | 已接入 |
|
||||||
| `/api/public/lovart?view=pricing` | 定价页聚合数据 | Public API | 已接入 |
|
| `/api/public/lovart?view=pricing` | 定价页聚合数据 | Public API | 已接入 |
|
||||||
| `/api/public/lovart?view=news` | 新闻页聚合数据 | Public API | 已接入 |
|
| `/api/public/lovart?view=news` | 新闻页聚合数据 | Public API | 已接入 |
|
||||||
|
| `/api/public/lovart?view=blog` | 博客页聚合数据 | Public API | 已接入 |
|
||||||
|
| `/api/public/lovart?view=changelog` | 更新日志聚合数据 | Public API | 已接入 |
|
||||||
|
|
||||||
## 新增查询与 API
|
## 新增查询与 API
|
||||||
|
|
||||||
- 查询函数(`lib/db/queries.ts`)
|
- 查询函数(`lib/db/queries.ts`)
|
||||||
- `getPublicFeaturedImages(limit)`
|
- `getPublicFeaturedImages(limit)`
|
||||||
- `getPublicFeaturedRanking(limit)`
|
- `getPublicFeaturedRanking(limit)`
|
||||||
|
- `getPublicChangelogEvents(limit)`
|
||||||
- `getTutorialVideos()`(改为静态导入,减少运行时开销)
|
- `getTutorialVideos()`(改为静态导入,减少运行时开销)
|
||||||
- 公开接口(`app/api/public/lovart/route.ts`)
|
- 公开接口(`app/api/public/lovart/route.ts`)
|
||||||
- `GET /api/public/lovart?view=home|pricing|news`
|
- `GET /api/public/lovart?view=home|pricing|news|blog|changelog`
|
||||||
- 返回公共营销页所需聚合数据(含短缓存头)
|
- 返回公共营销页所需聚合数据(含短缓存头)
|
||||||
|
|
||||||
## 页面层联动点
|
## 页面层联动点
|
||||||
|
|||||||
@ -1681,6 +1681,70 @@ export async function getPublicFeaturedRanking(limit = 5) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPublicChangelogEvents(limit = 18) {
|
||||||
|
try {
|
||||||
|
const [tutorialEvents, featuredEvents] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: tutorialVideos.id,
|
||||||
|
title: tutorialVideos.title,
|
||||||
|
description: tutorialVideos.description,
|
||||||
|
link: tutorialVideos.videoUrl,
|
||||||
|
imageUrl: tutorialVideos.coverUrl,
|
||||||
|
occurredAt: tutorialVideos.updatedAt,
|
||||||
|
createdAt: tutorialVideos.createdAt,
|
||||||
|
})
|
||||||
|
.from(tutorialVideos)
|
||||||
|
.where(eq(tutorialVideos.isEnabled, true))
|
||||||
|
.orderBy(desc(tutorialVideos.updatedAt), desc(tutorialVideos.createdAt))
|
||||||
|
.limit(limit),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: generatedImage.id,
|
||||||
|
prompt: generatedImage.prompt,
|
||||||
|
modelId: generatedImage.modelId,
|
||||||
|
imageUrl: generatedImage.thumbnailUrl,
|
||||||
|
link: generatedImage.originalUrl,
|
||||||
|
occurredAt: generatedImage.featuredAt,
|
||||||
|
createdAt: generatedImage.createdAt,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(eq(generatedImage.isFeatured, true))
|
||||||
|
.orderBy(desc(generatedImage.featuredAt), desc(generatedImage.createdAt))
|
||||||
|
.limit(limit),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
...tutorialEvents.map((item) => ({
|
||||||
|
id: `tutorial-${item.id}`,
|
||||||
|
kind: "tutorial" as const,
|
||||||
|
title: item.title,
|
||||||
|
summary: item.description || "新增教程内容",
|
||||||
|
detail: "教程内容更新",
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
href: item.link,
|
||||||
|
occurredAt: item.occurredAt || item.createdAt,
|
||||||
|
})),
|
||||||
|
...featuredEvents.map((item) => ({
|
||||||
|
id: `featured-${item.id}`,
|
||||||
|
kind: "featured" as const,
|
||||||
|
title: item.prompt || "新增精选作品",
|
||||||
|
summary: "精选画廊新增作品",
|
||||||
|
detail: item.modelId || "AI model",
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
href: item.link,
|
||||||
|
occurredAt: item.occurredAt || item.createdAt,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return events
|
||||||
|
.sort((a, b) => (b.occurredAt?.getTime() ?? 0) - (a.occurredAt?.getTime() ?? 0))
|
||||||
|
.slice(0, limit);
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取视频教程列表
|
// 获取视频教程列表
|
||||||
export async function getTutorialVideos() {
|
export async function getTutorialVideos() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user