️ feat: Refactor API client to support server-side and client-side configurations

fix: Update post fetching logic to use dynamic ID instead of hardcoded value

feat: Enhance layout with animated transitions using framer-motion

refactor: Remove old post and user page implementations, introduce new structure

feat: Implement sidebar components for blog home with dynamic content

feat: Create blog post component with wave header and metadata display

feat: Add responsive sidebar menu for navigation on mobile devices

chore: Introduce reusable sheet component for modal-like functionality
This commit is contained in:
2025-07-25 06:18:24 +08:00
parent a76f03038c
commit c565b5b5ef
17 changed files with 824 additions and 241 deletions

View File

@ -1,10 +1,14 @@
import axios from "axios";
import { camelToSnakeObj, snakeToCamelObj } from "field-conv";
export const BACKEND_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://neo-blog-backend:8888";
const isServer = typeof window === "undefined";
const API_SUFFIX = "/api/v1";
const axiosClient = axios.create({
baseURL: API_SUFFIX,
baseURL: isServer ? BACKEND_URL + API_SUFFIX : API_SUFFIX,
timeout: 10000,
});

View File

@ -11,8 +11,9 @@ interface ListPostsParams {
}
export async function getPostById(id: string): Promise<Post | null> {
console.log("Fetching post by ID:", id);
try {
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/${id}`);
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/19`);
return res.data.data;
} catch (error) {
console.error("Error fetching post by ID:", error);

View File

@ -1,18 +1,35 @@
import { Navbar } from "@/components/navbar";
"use client";
import { Navbar } from "@/components/navbar";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const pathname = usePathname();
return (
<>
<header className="flex justify-center">
<header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
<Navbar />
</header>
<main>
{children}
</main>
<AnimatePresence mode="wait">
<motion.main
key={pathname}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{
type: "tween",
ease: "easeOut",
duration: 0.18
}}
className="pt-16"
>
{children}
</motion.main>
</AnimatePresence>
</>
);
}

View File

@ -0,0 +1,13 @@
import { getPostById } from "@/api/post";
import BlogPost from "@/components/blog-post";
interface Props {
params: Promise<{ id: string }>
}
export default async function PostPage({ params }: Props) {
const { id } = await params;
const post = await getPostById(id);
if (!post) return <div></div>;
return <BlogPost post={post} />;
}

View File

@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
{children}
</>
);
}

View File

@ -1,82 +0,0 @@
import { getPostById } from "@/api/post";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{
id: string;
}>
}
export default async function PostPage({ params }: Props) {
const { id } = await params;
try {
const post = await getPostById(id);
if (!post) {
notFound();
}
return (
<article className="max-w-4xl mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-4">
<time>{new Date(post.createdAt).toLocaleDateString()}</time>
<span className="mx-2">·</span>
<span>: {post.viewCount || 0}</span>
</div>
</header>
<div className="prose max-w-none">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
{post.labels && post.labels.length > 0 && (
<footer className="mt-8 pt-8 border-t">
<div className="flex flex-wrap gap-2">
{post.labels.map((label) => (
<span key={label.id} className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
#{label.key}
</span>
))}
</div>
</footer>
)}
</article>
);
} catch (error) {
console.error("Failed to fetch post:", error);
notFound();
}
}
// 生成元数据
export async function generateMetadata({ params }: Props) {
const { id } = await params;
try {
const post = await getPostById(id);
if (!post) {
return {
title: '文章未找到',
};
}
return {
title: post.title,
description: post.content?.substring(0, 160),
openGraph: {
title: post.title,
description: post.content?.substring(0, 160),
type: 'article',
publishedTime: post.createdAt,
},
};
} catch (error) {
return {
title: '文章未找到',
};
}
}

View File

@ -0,0 +1,177 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Heart, TrendingUp, Eye } from "lucide-react";
import GravatarAvatar from "./gravatar";
import { Badge } from "@/components/ui/badge";
import type { Label } from "@/models/label";
import type { Post } from "@/models/post";
import type configType from '@/config';
import { t } from "i18next";
import { useEffect, useState } from "react";
// 侧边栏父组件,接收卡片组件列表
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
return (
<div className="lg:col-span-1 space-y-6 self-start">
{cards.map((card, idx) => (
<div key={idx}>{card}</div>
))}
</div>
);
}
// 关于我卡片
export function SidebarAbout({ config }: { config: typeof configType }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Heart className="w-5 h-5 text-red-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center mb-4">
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" />
</div>
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
<p className="text-sm text-slate-600">{config.owner.motto}</p>
</div>
<p className="text-sm text-slate-600 leading-relaxed">
{config.owner.description}
</p>
</CardContent>
</Card>
);
}
// 热门文章卡片
export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType: string }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-orange-500" />
{sortType === 'latest' ? '热门文章' : '最新文章'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{posts.slice(0, 3).map((post, index) => (
<div key={post.id} className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm line-clamp-2 mb-1">
{post.title}
</h4>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{post.viewCount}
</span>
<span className="flex items-center gap-1">
<Heart className="w-3 h-3" />
{post.likeCount}
</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
);
}
// 标签云卡片
export function SidebarTags({ labels }: { labels: Label[] }) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{labels.map((label) => (
<Badge
key={label.id}
variant="outline"
className="text-xs hover:bg-blue-50 cursor-pointer"
>
{label.key}
</Badge>
))}
</div>
</CardContent>
</Card>
);
}
export function SidebarIframe(props?: { src?: string; scriptSrc?: string; title?: string; height?: string }) {
const {
src = "",
scriptSrc = "",
title = "External Content",
height = "400px",
} = props || {};
return (
<Card>
<CardHeader>
<CardTitle>{t(title)}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<iframe
src={src}
className="w-full border-none"
style={{ height }}
height={height}
title={title}
/>
{scriptSrc && (
<script
src={scriptSrc}
async
defer
className="w-full"
></script>
)}
</CardContent>
</Card>
);
}
// 只在客户端渲染 iframe避免 hydration 报错
export function SidebarMisskeyIframe() {
const [show, setShow] = useState(false);
useEffect(() => {
setShow(true);
}, []);
return (
<Card>
<CardHeader>
<CardTitle>Misskey</CardTitle>
</CardHeader>
<CardContent className="p-0">
{show && (
<>
<iframe
src="https://lab.liteyuki.org/embed/user-timeline/a2utaz241qx60001?maxHeight=700&border=false"
data-misskey-embed-id="v1_aali1lvxt0"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{
border: "none",
width: "100%",
maxWidth: "500px",
height: "300px",
colorScheme: "light dark"
}}
></iframe>
<script defer src="https://lab.liteyuki.org/embed.js"></script>
</>
)}
</CardContent>
</Card>
);
}

View File

@ -2,10 +2,8 @@
import { BlogCardGrid } from "@/components/blog-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, TrendingUp, Clock, Heart, Eye } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, Clock, } from "lucide-react";
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "./blog-home-sidebar";
import config from '@/config';
import type { Label } from "@/models/label";
import type { Post } from "@/models/post";
@ -14,7 +12,6 @@ import { listPosts } from "@/api/post";
import { useEffect, useState } from "react";
import { useStoredState } from '@/hooks/use-storage-state';
import { listLabels } from "@/api/label";
import GravatarAvatar from "./gravatar";
import { POST_SORT_TYPE } from "@/localstore";
// 定义排序类型
@ -25,11 +22,11 @@ export default function BlogHome() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
const [debouncedSearch, setDebouncedSearch] = useState("");
// 根据排序类型获取文章
// 根据排序类型和防抖后的搜索关键词获取文章
useEffect(() => {
if (!sortTypeLoaded) return; // 等待从 localStorage 加载完成
if (!sortTypeLoaded) return;
const fetchPosts = async () => {
try {
setLoading(true);
@ -48,14 +45,16 @@ export default function BlogHome() {
orderedBy = 'updated_at';
reverse = false;
}
// 处理关键词,空格分割转逗号
const keywords = debouncedSearch.trim() ? debouncedSearch.trim().split(/\s+/).join(",") : undefined;
const data = await listPosts({
page: 1,
size: 10,
orderedBy,
reverse
reverse,
keywords
});
setPosts(data.data);
console.log(`${sortType} posts:`, data.data);
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
@ -69,14 +68,11 @@ export default function BlogHome() {
useEffect(() => {
listLabels().then(data => {
setLabels(data.data || []);
console.log("Labels:", data.data);
}).catch(error => {
console.error("Failed to fetch labels:", error);
});
}, []);
//
// 处理排序切换
const handleSortChange = (type: SortType) => {
if (sortType !== type) {
@ -85,54 +81,14 @@ export default function BlogHome() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:bg-gradient-to-br dark:from-slate-900 dark:via-blue-900 dark:to-indigo-900">
{/* Hero Section */}
<section className="relative py-10 lg:py-16 overflow-hidden">
{/* 背景装饰 */}
<div className="absolute inset-0 bg-grid-white/[0.02] bg-grid-16 pointer-events-none" />
{/* 容器 - 关键布局 */}
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center max-w-4xl mx-auto">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold mb-6 bg-gradient-to-r from-slate-900 via-blue-900 to-slate-900 bg-clip-text text-transparent dark:from-slate-100 dark:via-blue-100 dark:to-slate-100">
{config.metadata.name}
</h1>
<p className="text-xl md:text-2xl text-slate-600 mb-8 max-w-2xl mx-auto leading-relaxed">
{config.metadata.description}
</p>
{/* 搜索框 */}
<div className="relative max-w-md mx-auto mb-0">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
<Input
placeholder="搜索文章..."
className="pl-10 pr-4 py-0 text-lg border-slate-200 focus:border-blue-500 rounded-full"
/>
</div>
{/* 热门标签 */}
{/* <div className="flex flex-wrap justify-center gap-2 mb-8">
{labels.map(label => (
<Badge
key={label.id}
variant="outline"
className="text-xs hover:bg-blue-50 cursor-pointer"
>
{label.key}
</Badge>
))}
</div> */}
</div>
</div>
</section>
<>
{/* 主内容区域 */}
<section className="py-16">
{/* 容器 - 关键布局 */}
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容区域 */}
<div className="lg:col-span-3">
<div className="lg:col-span-3 self-start">
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
@ -193,88 +149,17 @@ export default function BlogHome() {
</div>
{/* 侧边栏 */}
<div className="lg:col-span-1 space-y-6">
{/* 关于我 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Heart className="w-5 h-5 text-red-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center mb-4">
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" />
</div>
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
<p className="text-sm text-slate-600">{config.owner.motto}</p>
</div>
<p className="text-sm text-slate-600 leading-relaxed">
{config.owner.description}
</p>
</CardContent>
</Card>
{/* 热门文章 */}
{posts.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-orange-500" />
{sortType === 'latest' ? '热门文章' : '最新文章'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{posts.slice(0, 3).map((post, index) => (
<div key={post.id} className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm line-clamp-2 mb-1">
{post.title}
</h4>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{post.viewCount}
</span>
<span className="flex items-center gap-1">
<Heart className="w-3 h-3" />
{post.likeCount}
</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* 标签云 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{labels.map((label) => (
<Badge
key={label.id}
variant="outline"
className="text-xs hover:bg-blue-50 cursor-pointer"
>
{label.key}
</Badge>
))}
</div>
</CardContent>
</Card>
</div>
<Sidebar
cards={[
<SidebarAbout key="about" config={config} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
<SidebarTags key="tags" labels={labels} />,
<SidebarMisskeyIframe key="misskey" />,
].filter(Boolean)}
/>
</div>
</div>
</section>
</div>
</>
);
}

View File

@ -0,0 +1,108 @@
"use client";
import { useEffect } from "react";
import type { Post } from "@/models/post";
function WaveHeader({ title }: { title: string }) {
return (
<div className="relative h-64 flex flex-col items-center justify-center">
{/* 波浪SVG半透明悬浮 */}
<div className="absolute inset-0 w-full h-full pointer-events-none opacity-70 z-0">
<svg className="w-full h-full" viewBox="0 0 1440 320" preserveAspectRatio="none">
<defs>
<linearGradient id="wave-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#4f8cff" />
<stop offset="100%" stopColor="#7b61ff" />
</linearGradient>
</defs>
<path
fill="url(#wave-gradient)"
fillOpacity="1"
d="
M0,160
C360,240 1080,80 1440,160
L1440,320
L0,320
Z
"
>
<animate
attributeName="d"
dur="8s"
repeatCount="indefinite"
values="
M0,160 C360,240 1080,80 1440,160 L1440,320 L0,320 Z;
M0,120 C400,200 1040,120 1440,200 L1440,320 L0,320 Z;
M0,160 C360,240 1080,80 1440,160 L1440,320 L0,320 Z
"
/>
</path>
</svg>
</div>
{/* 标题 */}
<h1 className="relative z-10 text-white text-4xl md:text-5xl font-bold drop-shadow-lg mt-16 text-center">
{title}
</h1>
</div>
);
}
function BlogMeta({ post }: { post: Post }) {
return (
<div className="flex flex-col items-center mb-6">
<div className="flex gap-2 mb-2">
{post.labels?.map(label => (
<span
key={label.id}
className="bg-white/30 px-3 py-1 rounded-full text-sm font-medium backdrop-blur text-white"
>
{label.key}
</span>
))}
</div>
<div className="flex flex-wrap gap-4 text-base text-white/90">
<span> {new Date(post.createdAt).toLocaleDateString()}</span>
<span>👁 {post.viewCount}</span>
<span>💬 {post.commentCount}</span>
<span>🔥 {post.heat}</span>
</div>
</div>
);
}
function BlogContent({ post }: { post: Post }) {
return (
<main className="relative z-10 max-w-3xl mx-auto bg-white rounded-xl shadow-lg p-8 -mt-32">
{post.cover && (
<img
src={post.cover}
alt="cover"
className="w-full h-64 object-cover rounded-lg mb-8"
/>
)}
<article
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</main>
);
}
function BlogPost({ post }: { post: Post }) {
useEffect(() => {
if (typeof window !== "undefined") {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 relative">
<WaveHeader title={post.title} />
<div className="relative z-10 -mt-40">
<BlogMeta post={post} />
<BlogContent post={post} />
</div>
</div>
);
}
export default BlogPost;

View File

@ -14,8 +14,10 @@ import {
} from "@/components/ui/navigation-menu"
import GravatarAvatar from "@/components/gravatar"
import { useDevice } from "@/contexts/device-context"
import { metadata } from '../app/layout';
import config from "@/config"
import { useState, useEffect } from "react"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Menu } from "lucide-react"
const navbarMenuComponents = [
{
@ -43,24 +45,22 @@ const navbarMenuComponents = [
export function Navbar() {
return (
<nav className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-12 px-4 w-full">
<nav className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full">
<div className="flex items-center justify-start">
{/* 左侧内容 */}
<span className="font-bold truncate">{config.metadata.name}</span>
</div>
<div className="flex items-center justify-center">
{/* 中间内容 - 完全居中 */}
<NavMenu />
<NavMenuCenter />
</div>
<div className="flex items-center justify-end">
{/* 右侧内容 */}
<GravatarAvatar email="snowykami@outlook.com"/>
<div className="flex items-center justify-end space-x-2">
<GravatarAvatar email="snowykami@outlook.com" />
<SidebarMenuClientOnly />
</div>
</nav>
)
}
function NavMenu() {
function NavMenuCenter() {
const { isMobile } = useDevice()
console.log("isMobile", isMobile)
if (isMobile) return null
@ -119,3 +119,64 @@ function ListItem({
</li>
)
}
function SidebarMenuClientOnly() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <SidebarMenu />;
}
function SidebarMenu() {
const [open, setOpen] = useState(false)
const { isMobile } = useDevice()
if (!isMobile) return null
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
aria-label="打开菜单"
className="p-2 rounded-md hover:bg-accent transition-colors"
>
<Menu className="w-6 h-6" />
</button>
</SheetTrigger>
<SheetContent side="right" className="p-0 w-64">
{/* 可访问性要求的标题,视觉上隐藏 */}
<SheetTitle className="sr-only"></SheetTitle>
<nav className="flex flex-col gap-2 p-4">
{navbarMenuComponents.map((item) =>
item.href ? (
<Link
key={item.title}
href={item.href}
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
onClick={() => setOpen(false)}
>
{item.title}
</Link>
) : item.children ? (
<div key={item.title} className="mb-2">
<div className="font-bold px-3 py-2">{item.title}</div>
<div className="flex flex-col pl-4">
{item.children.map((child) => (
<Link
key={child.title}
href={child.href}
className="py-2 px-3 rounded hover:bg-accent transition-colors"
onClick={() => setOpen(false)}
>
{child.title}
</Link>
))}
</div>
</div>
) : null
)}
</nav>
</SheetContent>
</Sheet>
)
}

View File

@ -1 +0,0 @@

View File

@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}