feat: Banner UI美化 & 新增文章/公告/图库/媒体管理等功能
- Banner: Ken Burns缩放动效、左右导航箭头、进度条指示器、hover暂停、暗角遮罩、shimmer按钮动画 - 新增文章管理(CRUD)与公开文章页 - 新增Banner/Gallery图片管理API - 新增媒体管理页面 - 新增更新日志页面 - 新增页面访问追踪 - 新增Markdown渲染组件 - .gitignore排除.cursor目录 Made-with: Cursor
This commit is contained in:
67
src/app/(public)/articles/[slug]/page.tsx
Normal file
67
src/app/(public)/articles/[slug]/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Calendar, ArrowLeft } from "lucide-react";
|
||||
import { MarkdownContent } from "@/components/public/MarkdownContent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ArticleDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { slug, published: true },
|
||||
});
|
||||
|
||||
if (!article) notFound();
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
|
||||
<Link
|
||||
href="/articles"
|
||||
className="mb-6 inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回文章列表
|
||||
</Link>
|
||||
|
||||
<h1 className="mb-3 text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 flex items-center gap-1.5 text-sm text-gray-500">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{new Date(article.createdAt).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{article.coverImage && (
|
||||
<div className="mb-8 overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={article.coverImage}
|
||||
alt={article.title}
|
||||
className="w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MarkdownContent content={article.content} />
|
||||
|
||||
<div className="mt-12 border-t border-amber-500/10 pt-6">
|
||||
<Link
|
||||
href="/articles"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回文章列表
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
61
src/app/(public)/articles/page.tsx
Normal file
61
src/app/(public)/articles/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ArticlesPage() {
|
||||
const articles = await prisma.article.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-4xl px-3 py-10 sm:px-4 sm:py-16">
|
||||
<h1 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||
公告与文章
|
||||
</h1>
|
||||
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
|
||||
最新动态与更新公告
|
||||
</p>
|
||||
|
||||
{articles.length === 0 ? (
|
||||
<p className="py-16 text-center text-gray-500">暂无文章</p>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{articles.map((article) => (
|
||||
<Link
|
||||
key={article.id}
|
||||
href={`/articles/${article.slug}`}
|
||||
className="group overflow-hidden rounded-lg border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
|
||||
>
|
||||
{article.coverImage && (
|
||||
<div className="aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
src={article.coverImage}
|
||||
alt={article.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 sm:p-5">
|
||||
<h2 className="mb-2 text-lg font-semibold text-amber-100 group-hover:text-amber-200 sm:text-xl">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.summary && (
|
||||
<p className="mb-3 line-clamp-2 text-sm text-gray-400">
|
||||
{article.summary}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
97
src/app/(public)/changelog/page.tsx
Normal file
97
src/app/(public)/changelog/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Download, Calendar, Tag } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChangelogPage() {
|
||||
const software = await prisma.software.findUnique({
|
||||
where: { slug: "nanami-launcher" },
|
||||
include: {
|
||||
versions: {
|
||||
orderBy: { versionCode: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const versions = software?.versions ?? [];
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
|
||||
<h1 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||
版本历史
|
||||
</h1>
|
||||
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
|
||||
Nanami 启动器更新日志
|
||||
</p>
|
||||
|
||||
{versions.length === 0 ? (
|
||||
<p className="py-16 text-center text-gray-500">暂无版本记录</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[15px] top-2 bottom-0 w-px bg-amber-500/20 sm:left-[19px]" />
|
||||
|
||||
<div className="space-y-8 sm:space-y-10">
|
||||
{versions.map((v, idx) => (
|
||||
<div key={v.id} className="relative pl-10 sm:pl-12">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className={`absolute left-[10px] top-1.5 h-3 w-3 rounded-full border-2 sm:left-[13px] sm:h-3.5 sm:w-3.5 ${
|
||||
idx === 0
|
||||
? "border-amber-400 bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]"
|
||||
: "border-amber-500/40 bg-[#0d0b15]"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-amber-500/10 bg-white/[0.03] p-4 sm:p-5">
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<h2 className="text-lg font-semibold text-amber-100 sm:text-xl">
|
||||
v{v.version}
|
||||
</h2>
|
||||
{v.isLatest && (
|
||||
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300 sm:text-xs">
|
||||
最新版本
|
||||
</span>
|
||||
)}
|
||||
{v.forceUpdate && (
|
||||
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-400 sm:text-xs">
|
||||
强制更新
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-gray-500 sm:gap-4 sm:text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
Build {v.versionCode}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
{v.downloadCount.toLocaleString()} 次下载
|
||||
</span>
|
||||
{v.fileSize > 0 && (
|
||||
<span className="text-gray-600">
|
||||
{(v.fileSize / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300 sm:text-[15px]">
|
||||
{v.changelog || "无更新说明"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { PageTracker } from "@/components/public/PageTracker";
|
||||
|
||||
export default function PublicLayout({
|
||||
children,
|
||||
@@ -11,6 +12,7 @@ export default function PublicLayout({
|
||||
<Navbar />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
<PageTracker />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,51 +3,73 @@ import { prisma } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AddonCard } from "@/components/public/AddonCard";
|
||||
import { HeroBanner } from "@/components/public/HeroBanner";
|
||||
import { Sparkles, Shield, Zap } from "lucide-react";
|
||||
import { GameGallery } from "@/components/public/GameGallery";
|
||||
import { Sparkles, Shield, Zap, Calendar } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const [featuredAddons, totalDownloads, launcher] = await Promise.all([
|
||||
prisma.addon.findMany({
|
||||
where: { published: true },
|
||||
include: {
|
||||
releases: {
|
||||
where: { isLatest: true },
|
||||
select: { version: true },
|
||||
const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] =
|
||||
await Promise.all([
|
||||
prisma.addon.findMany({
|
||||
where: { published: true },
|
||||
include: {
|
||||
releases: {
|
||||
where: { isLatest: true },
|
||||
select: { version: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { totalDownloads: "desc" },
|
||||
take: 6,
|
||||
}),
|
||||
prisma.addon.aggregate({
|
||||
_sum: { totalDownloads: true },
|
||||
}),
|
||||
prisma.software.findUnique({
|
||||
where: { slug: "nanami-launcher" },
|
||||
include: {
|
||||
versions: {
|
||||
where: { isLatest: true },
|
||||
take: 1,
|
||||
orderBy: { totalDownloads: "desc" },
|
||||
take: 6,
|
||||
}),
|
||||
prisma.software.findUnique({
|
||||
where: { slug: "nanami-launcher" },
|
||||
include: {
|
||||
versions: {
|
||||
where: { isLatest: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
prisma.softwareVersion.aggregate({
|
||||
where: { software: { slug: "nanami-launcher" } },
|
||||
_sum: { downloadCount: true },
|
||||
}),
|
||||
prisma.bannerImage.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { imageUrl: true },
|
||||
}),
|
||||
prisma.galleryImage.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { imageUrl: true, title: true },
|
||||
}),
|
||||
prisma.article.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 3,
|
||||
}),
|
||||
]);
|
||||
|
||||
const launcherVersion = launcher?.versions[0]?.version ?? null;
|
||||
const totalDownloads = launcherDownloads._sum.downloadCount ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeroBanner
|
||||
totalDownloads={totalDownloads._sum.totalDownloads ?? undefined}
|
||||
totalDownloads={totalDownloads || undefined}
|
||||
launcherVersion={launcherVersion}
|
||||
banners={banners}
|
||||
/>
|
||||
|
||||
<GameGallery items={galleryImages} />
|
||||
|
||||
{/* Features */}
|
||||
<section className="relative border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a] dark:from-[#0d0b15] dark:to-[#110f1a]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(168,85,247,0.06)_0%,transparent_70%)]" />
|
||||
<div className="relative mx-auto max-w-6xl px-4 py-16">
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<div className="relative mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
|
||||
<div className="grid gap-4 sm:gap-8 md:grid-cols-3">
|
||||
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-500/10">
|
||||
<Sparkles className="h-6 w-6 text-amber-400" />
|
||||
@@ -83,11 +105,62 @@ export default async function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Latest Articles */}
|
||||
{latestArticles.length > 0 && (
|
||||
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
|
||||
<div className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
|
||||
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||
<h2 className="text-2xl font-bold text-amber-100">最新公告</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-amber-500/20 text-amber-200 hover:border-amber-500/40 hover:bg-amber-500/10 hover:text-amber-100"
|
||||
render={<Link href="/articles" />}
|
||||
>
|
||||
查看更多
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-3">
|
||||
{latestArticles.map((article) => (
|
||||
<Link
|
||||
key={article.id}
|
||||
href={`/articles/${article.slug}`}
|
||||
className="group overflow-hidden rounded-xl border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
|
||||
>
|
||||
{article.coverImage && (
|
||||
<div className="aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
src={article.coverImage}
|
||||
alt={article.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="mb-1.5 font-semibold text-amber-100 group-hover:text-amber-200 line-clamp-1">
|
||||
{article.title}
|
||||
</h3>
|
||||
{article.summary && (
|
||||
<p className="mb-2 line-clamp-2 text-sm text-gray-400">
|
||||
{article.summary}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Featured Addons */}
|
||||
{featuredAddons.length > 0 && (
|
||||
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
|
||||
<div className="mx-auto max-w-6xl px-4 py-16">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
|
||||
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||
<h2 className="text-2xl font-bold text-amber-100">热门插件</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
33
src/app/admin/(dashboard)/articles/[id]/edit/page.tsx
Normal file
33
src/app/admin/(dashboard)/articles/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ArticleEditor } from "@/components/admin/ArticleEditor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditArticlePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const article = await prisma.article.findUnique({ where: { id } });
|
||||
|
||||
if (!article) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">编辑文章</h1>
|
||||
<ArticleEditor
|
||||
initial={{
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
summary: article.summary,
|
||||
content: article.content,
|
||||
coverImage: article.coverImage,
|
||||
published: article.published,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/admin/(dashboard)/articles/new/page.tsx
Normal file
10
src/app/admin/(dashboard)/articles/new/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ArticleEditor } from "@/components/admin/ArticleEditor";
|
||||
|
||||
export default function NewArticlePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">新建文章</h1>
|
||||
<ArticleEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/app/admin/(dashboard)/articles/page.tsx
Normal file
73
src/app/admin/(dashboard)/articles/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus } from "lucide-react";
|
||||
import { ArticleActions } from "@/components/admin/ArticleActions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ArticlesPage() {
|
||||
const articles = await prisma.article.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">文章管理</h1>
|
||||
<Button render={<Link href="/admin/articles/new" />}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建文章
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{articles.length === 0 ? (
|
||||
<p className="py-10 text-center text-muted-foreground">
|
||||
暂无文章,点击右上角创建第一篇
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{articles.map((article) => (
|
||||
<TableRow key={article.id}>
|
||||
<TableCell className="font-medium">{article.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={article.published ? "default" : "secondary"}>
|
||||
{article.published ? "已发布" : "草稿"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(article.updatedAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ArticleActions id={article.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/app/admin/(dashboard)/media/page.tsx
Normal file
38
src/app/admin/(dashboard)/media/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MediaManager } from "@/components/admin/MediaManager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MediaPage() {
|
||||
const [banners, gallery] = await Promise.all([
|
||||
prisma.bannerImage.findMany({ orderBy: { sortOrder: "asc" } }),
|
||||
prisma.galleryImage.findMany({ orderBy: { sortOrder: "asc" } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">媒体管理</h1>
|
||||
<p className="text-muted-foreground">
|
||||
管理首页 Banner 轮播图和实机演示画廊图片
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="banner">
|
||||
<TabsList>
|
||||
<TabsTrigger value="banner">Banner 管理</TabsTrigger>
|
||||
<TabsTrigger value="gallery">画廊管理</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="banner" className="mt-6">
|
||||
<MediaManager type="banner" initial={JSON.parse(JSON.stringify(banners))} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gallery" className="mt-6">
|
||||
<MediaManager type="gallery" initial={JSON.parse(JSON.stringify(gallery))} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,39 +6,70 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Package, Download, FileUp } from "lucide-react";
|
||||
import { Package, Download, FileUp, Eye, Users, TrendingUp } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getLast7Days() {
|
||||
const days: string[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
days.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const [addonCount, totalDownloads, releaseCount, recentReleases] =
|
||||
await Promise.all([
|
||||
prisma.addon.count(),
|
||||
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
|
||||
prisma.release.count(),
|
||||
prisma.release.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { addon: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const days = getLast7Days();
|
||||
|
||||
const [
|
||||
addonCount,
|
||||
totalDownloads,
|
||||
releaseCount,
|
||||
recentReleases,
|
||||
todayPV,
|
||||
totalPV,
|
||||
todayUV,
|
||||
pvByDay,
|
||||
] = await Promise.all([
|
||||
prisma.addon.count(),
|
||||
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
|
||||
prisma.release.count(),
|
||||
prisma.release.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { addon: { select: { name: true } } },
|
||||
}),
|
||||
prisma.pageView.count({ where: { date: today } }),
|
||||
prisma.pageView.count(),
|
||||
prisma.pageView.groupBy({
|
||||
by: ["ip"],
|
||||
where: { date: today, ip: { not: "" } },
|
||||
}).then((r) => r.length),
|
||||
prisma.pageView.groupBy({
|
||||
by: ["date"],
|
||||
where: { date: { in: days } },
|
||||
_count: true,
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const pvMap = new Map(pvByDay.map((d) => [d.date, d._count]));
|
||||
const chartData = days.map((d) => ({
|
||||
date: d.slice(5),
|
||||
pv: pvMap.get(d) || 0,
|
||||
}));
|
||||
const maxPV = Math.max(...chartData.map((d) => d.pv), 1);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "插件总数",
|
||||
value: addonCount,
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: "总下载量",
|
||||
value: totalDownloads._sum.totalDownloads || 0,
|
||||
icon: Download,
|
||||
},
|
||||
{
|
||||
title: "版本发布数",
|
||||
value: releaseCount,
|
||||
icon: FileUp,
|
||||
},
|
||||
{ title: "插件总数", value: addonCount, icon: Package },
|
||||
{ title: "总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download },
|
||||
{ title: "版本发布数", value: releaseCount, icon: FileUp },
|
||||
{ title: "今日访问 (PV)", value: todayPV, icon: Eye },
|
||||
{ title: "今日独立访客 (UV)", value: todayUV, icon: Users },
|
||||
{ title: "累计访问量", value: totalPV, icon: TrendingUp },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -55,12 +86,42 @@ export default async function DashboardPage() {
|
||||
<stat.icon className="h-5 w-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{stat.value}</div>
|
||||
<div className="text-3xl font-bold">
|
||||
{stat.value.toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 7-day PV chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>近 7 天访问趋势</CardTitle>
|
||||
<CardDescription>页面浏览量 (PV)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-48 items-end gap-2">
|
||||
{chartData.map((d) => (
|
||||
<div key={d.date} className="flex flex-1 flex-col items-center gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{d.pv}
|
||||
</span>
|
||||
<div
|
||||
className="w-full rounded-t bg-primary/80 transition-all"
|
||||
style={{
|
||||
height: `${Math.max((d.pv / maxPV) * 160, 4)}px`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{d.date}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最近发布</CardTitle>
|
||||
|
||||
75
src/app/api/articles/[id]/route.ts
Normal file
75
src/app/api/articles/[id]/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const article = await prisma.article.findFirst({
|
||||
where: { OR: [{ id }, { slug: id }] },
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(article);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
if (body.slug) {
|
||||
const existing = await prisma.article.findFirst({
|
||||
where: { slug: body.slug, NOT: { id } },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Slug already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const article = await prisma.article.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.title !== undefined && { title: body.title }),
|
||||
...(body.slug !== undefined && { slug: body.slug }),
|
||||
...(body.summary !== undefined && { summary: body.summary }),
|
||||
...(body.content !== undefined && { content: body.content }),
|
||||
...(body.coverImage !== undefined && {
|
||||
coverImage: body.coverImage || null,
|
||||
}),
|
||||
...(body.published !== undefined && { published: body.published }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(article);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.article.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
57
src/app/api/articles/route.ts
Normal file
57
src/app/api/articles/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const publishedOnly = searchParams.get("published") !== "false";
|
||||
const limit = searchParams.get("limit");
|
||||
|
||||
const where = publishedOnly ? { published: true } : {};
|
||||
|
||||
const articles = await prisma.article.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
...(limit ? { take: parseInt(limit, 10) } : {}),
|
||||
});
|
||||
|
||||
return NextResponse.json(articles);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, slug, summary, content, coverImage, published } = body;
|
||||
|
||||
if (!title || !slug) {
|
||||
return NextResponse.json(
|
||||
{ error: "title and slug are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.article.findUnique({ where: { slug } });
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Slug already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const article = await prisma.article.create({
|
||||
data: {
|
||||
title,
|
||||
slug,
|
||||
summary: summary || "",
|
||||
content: content || "",
|
||||
coverImage: coverImage || null,
|
||||
published: published ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(article, { status: 201 });
|
||||
}
|
||||
37
src/app/api/banners/[id]/route.ts
Normal file
37
src/app/api/banners/[id]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
||||
if (body.enabled !== undefined) data.enabled = body.enabled;
|
||||
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
|
||||
|
||||
const updated = await prisma.bannerImage.update({ where: { id }, data });
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.bannerImage.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
30
src/app/api/banners/route.ts
Normal file
30
src/app/api/banners/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
|
||||
const where = enabledOnly ? { enabled: true } : {};
|
||||
const banners = await prisma.bannerImage.findMany({
|
||||
where,
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
return NextResponse.json(banners);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const banner = await prisma.bannerImage.create({
|
||||
data: {
|
||||
imageUrl: body.imageUrl,
|
||||
sortOrder: body.sortOrder ?? 0,
|
||||
enabled: body.enabled ?? true,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(banner);
|
||||
}
|
||||
38
src/app/api/gallery/[id]/route.ts
Normal file
38
src/app/api/gallery/[id]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
||||
if (body.enabled !== undefined) data.enabled = body.enabled;
|
||||
if (body.title !== undefined) data.title = body.title;
|
||||
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
|
||||
|
||||
const updated = await prisma.galleryImage.update({ where: { id }, data });
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.galleryImage.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
31
src/app/api/gallery/route.ts
Normal file
31
src/app/api/gallery/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
|
||||
const where = enabledOnly ? { enabled: true } : {};
|
||||
const images = await prisma.galleryImage.findMany({
|
||||
where,
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
return NextResponse.json(images);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const image = await prisma.galleryImage.create({
|
||||
data: {
|
||||
imageUrl: body.imageUrl,
|
||||
title: body.title ?? "",
|
||||
sortOrder: body.sortOrder ?? 0,
|
||||
enabled: body.enabled ?? true,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(image);
|
||||
}
|
||||
36
src/app/api/seed/route.ts
Normal file
36
src/app/api/seed/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const key = request.nextUrl.searchParams.get("key");
|
||||
if (key !== "nanami-init-2024") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const bannerCount = await prisma.bannerImage.count();
|
||||
const galleryCount = await prisma.galleryImage.count();
|
||||
|
||||
let bannersCreated = 0;
|
||||
let galleryCreated = 0;
|
||||
|
||||
if (bannerCount === 0) {
|
||||
const banners = [
|
||||
{ imageUrl: "/banners/banner_2.png", sortOrder: 0 },
|
||||
{ imageUrl: "/banners/banner_1.png", sortOrder: 1 },
|
||||
{ imageUrl: "/banners/banner_3.png", sortOrder: 2 },
|
||||
];
|
||||
await prisma.bannerImage.createMany({ data: banners });
|
||||
bannersCreated = banners.length;
|
||||
}
|
||||
|
||||
if (galleryCount === 0) {
|
||||
const gallery = Array.from({ length: 14 }, (_, i) => ({
|
||||
imageUrl: `/views/view_${i + 1}.png`,
|
||||
sortOrder: i,
|
||||
}));
|
||||
await prisma.galleryImage.createMany({ data: gallery });
|
||||
galleryCreated = gallery.length;
|
||||
}
|
||||
|
||||
return NextResponse.json({ bannersCreated, galleryCreated });
|
||||
}
|
||||
10
src/app/api/server-time/route.ts
Normal file
10
src/app/api/server-time/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const now = new Date();
|
||||
return NextResponse.json({
|
||||
timestamp: now.getTime(),
|
||||
iso: now.toISOString(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
}
|
||||
@@ -37,9 +37,15 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const hasUpdate = latest.versionCode > clientVersionCode;
|
||||
|
||||
const downloadUrl = latest.downloadType === "url" && latest.externalUrl
|
||||
? latest.externalUrl
|
||||
: `${request.nextUrl.origin}/api/software/download/${latest.id}`;
|
||||
const origin =
|
||||
request.headers.get("x-forwarded-proto") && request.headers.get("host")
|
||||
? `${request.headers.get("x-forwarded-proto")}://${request.headers.get("host")}`
|
||||
: request.nextUrl.origin;
|
||||
|
||||
const downloadUrl =
|
||||
latest.downloadType === "url" && latest.externalUrl
|
||||
? latest.externalUrl
|
||||
: `${origin}/api/software/download/${latest.id}`;
|
||||
|
||||
return NextResponse.json({
|
||||
hasUpdate,
|
||||
|
||||
@@ -32,10 +32,19 @@ export async function GET(request: NextRequest) {
|
||||
const latest = software.versions[0];
|
||||
|
||||
if (infoOnly) {
|
||||
const track = searchParams.get("track") === "1";
|
||||
if (track) {
|
||||
await prisma.softwareVersion.update({
|
||||
where: { id: latest.id },
|
||||
data: { downloadCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
const downloadUrl =
|
||||
latest.downloadType === "url" && latest.externalUrl
|
||||
? latest.externalUrl
|
||||
: `/api/software/download/${latest.id}`;
|
||||
|
||||
return NextResponse.json({
|
||||
available: true,
|
||||
version: latest.version,
|
||||
|
||||
24
src/app/api/track/route.ts
Normal file
24
src/app/api/track/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const path = body.path || "/";
|
||||
const referrer = body.referrer || "";
|
||||
const ua = request.headers.get("user-agent") || "";
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"";
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
|
||||
await prisma.pageView.create({
|
||||
data: { path, referrer, userAgent: ua, ip, date },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -138,59 +138,275 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
height: 100px;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
filter: blur(30px);
|
||||
filter: blur(40px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hero-aurora--top {
|
||||
top: -30px;
|
||||
top: -40px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(168, 85, 247, 0.4) 15%,
|
||||
rgba(59, 130, 246, 0.3) 30%,
|
||||
rgba(236, 72, 153, 0.35) 50%,
|
||||
rgba(139, 92, 246, 0.4) 70%,
|
||||
rgba(6, 182, 212, 0.3) 85%,
|
||||
rgba(168, 85, 247, 0.45) 12%,
|
||||
rgba(59, 130, 246, 0.35) 28%,
|
||||
rgba(236, 72, 153, 0.4) 45%,
|
||||
rgba(245, 158, 11, 0.3) 60%,
|
||||
rgba(139, 92, 246, 0.45) 75%,
|
||||
rgba(6, 182, 212, 0.35) 88%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: auroraShift 8s ease-in-out infinite alternate;
|
||||
animation: auroraShift 10s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.hero-aurora--bottom {
|
||||
bottom: -30px;
|
||||
bottom: -40px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(245, 158, 11, 0.35) 15%,
|
||||
rgba(168, 85, 247, 0.3) 35%,
|
||||
rgba(6, 182, 212, 0.35) 55%,
|
||||
rgba(236, 72, 153, 0.3) 75%,
|
||||
rgba(245, 158, 11, 0.35) 90%,
|
||||
rgba(245, 158, 11, 0.4) 12%,
|
||||
rgba(168, 85, 247, 0.35) 30%,
|
||||
rgba(6, 182, 212, 0.4) 48%,
|
||||
rgba(236, 72, 153, 0.35) 65%,
|
||||
rgba(139, 92, 246, 0.3) 80%,
|
||||
rgba(245, 158, 11, 0.4) 92%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: auroraShift 8s ease-in-out infinite alternate-reverse;
|
||||
animation: auroraShift 10s ease-in-out infinite alternate-reverse;
|
||||
}
|
||||
|
||||
@keyframes auroraShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
opacity: 0.5;
|
||||
opacity: 0.45;
|
||||
filter: blur(35px);
|
||||
}
|
||||
50% {
|
||||
33% {
|
||||
opacity: 0.8;
|
||||
filter: blur(45px);
|
||||
}
|
||||
66% {
|
||||
opacity: 0.6;
|
||||
filter: blur(38px);
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
opacity: 0.5;
|
||||
opacity: 0.45;
|
||||
filter: blur(35px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Force background-size for aurora animation */
|
||||
.hero-aurora--top,
|
||||
.hero-aurora--bottom {
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* ---- Hero Banner Effects ---- */
|
||||
.hero-banner {
|
||||
border-bottom: 1px solid rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.hero-scanlines {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.015) 2px,
|
||||
rgba(0, 0, 0, 0.015) 4px
|
||||
);
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
text-shadow: 0 0 20px rgba(245, 158, 11, 0.2);
|
||||
animation: taglineFade 1.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes taglineFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-shimmer {
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.06) 45%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0.06) 55%,
|
||||
transparent 60%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
animation: shimmerSweep 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmerSweep {
|
||||
0%, 100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
50% {
|
||||
background-position: -50% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-download-btn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(245, 158, 11, 0.3),
|
||||
rgba(168, 85, 247, 0.15),
|
||||
rgba(245, 158, 11, 0.3)
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
animation: borderGlow 4s ease-in-out infinite;
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes borderGlow {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
.hero-indicator {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-shimmer,
|
||||
.hero-tagline {
|
||||
animation: none;
|
||||
}
|
||||
.hero-download-btn::before {
|
||||
animation: none;
|
||||
}
|
||||
.hero-scanlines {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Download button shimmer */
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* ---- Gallery Thumbnail Scrollbar ---- */
|
||||
.gallery-thumbs {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(251, 191, 36, 0.25) transparent;
|
||||
}
|
||||
.gallery-thumbs::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
.gallery-thumbs::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.gallery-thumbs::-webkit-scrollbar-thumb {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.gallery-thumbs::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(251, 191, 36, 0.45);
|
||||
}
|
||||
|
||||
/* ---- Article Markdown Prose ---- */
|
||||
.prose-article {
|
||||
color: #d1d5db;
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
}
|
||||
.prose-article h1,
|
||||
.prose-article h2,
|
||||
.prose-article h3,
|
||||
.prose-article h4 {
|
||||
color: #fef3c7;
|
||||
font-weight: 700;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
.prose-article h1 { font-size: 1.75em; }
|
||||
.prose-article h2 { font-size: 1.4em; border-bottom: 1px solid rgba(251,191,36,0.15); padding-bottom: 0.3em; }
|
||||
.prose-article h3 { font-size: 1.15em; }
|
||||
.prose-article p { margin-bottom: 1em; }
|
||||
.prose-article a {
|
||||
color: #fbbf24;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.prose-article a:hover { color: #fde68a; }
|
||||
.prose-article strong { color: #fef3c7; }
|
||||
.prose-article blockquote {
|
||||
border-left: 3px solid rgba(251,191,36,0.3);
|
||||
padding: 0.5em 1em;
|
||||
margin: 1em 0;
|
||||
color: #9ca3af;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.prose-article ul, .prose-article ol { padding-left: 1.5em; margin-bottom: 1em; }
|
||||
.prose-article li { margin-bottom: 0.3em; }
|
||||
.prose-article ul { list-style-type: disc; }
|
||||
.prose-article ol { list-style-type: decimal; }
|
||||
.prose-article code {
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.prose-article pre {
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 8px;
|
||||
padding: 1em;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose-article pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #d1d5db;
|
||||
}
|
||||
.prose-article img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.prose-article table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose-article th, .prose-article td {
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 0.5em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
.prose-article th {
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: #fef3c7;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-article hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(251,191,36,0.15);
|
||||
margin: 2em 0;
|
||||
}
|
||||
49
src/app/uploads/[...path]/route.ts
Normal file
49
src/app/uploads/[...path]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".zip": "application/zip",
|
||||
".exe": "application/octet-stream",
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const segments = await params;
|
||||
const filePath = path.join(process.cwd(), "uploads", ...segments.path);
|
||||
|
||||
const resolved = path.resolve(filePath);
|
||||
const uploadsDir = path.resolve(path.join(process.cwd(), "uploads"));
|
||||
if (!resolved.startsWith(uploadsDir)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(resolved);
|
||||
if (!info.isFile()) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const buffer = await readFile(resolved);
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const contentType = MIME[ext] || "application/octet-stream";
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user