@xexr/blog-platform 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/app/blog/[slug]/page.d.ts +68 -0
  3. package/dist/app/blog/[slug]/page.tsx +155 -113
  4. package/dist/components/ArticleReadingProgress.tsx +208 -0
  5. package/dist/components/ArticleTopActions.tsx +179 -0
  6. package/dist/components/AuthorByline.tsx +569 -0
  7. package/dist/components/ExpandableArticleImage.tsx +79 -0
  8. package/dist/components/Pagination.tsx +236 -0
  9. package/dist/components/TopicArchivePages.tsx +204 -0
  10. package/dist/components/ui/badge.tsx +36 -0
  11. package/dist/components/ui/button.tsx +59 -0
  12. package/dist/components/ui/card.tsx +92 -0
  13. package/dist/components/ui/pagination.tsx +127 -0
  14. package/dist/components/ui/separator.tsx +29 -0
  15. package/dist/index.d.mts +59 -69
  16. package/dist/index.d.ts +59 -69
  17. package/dist/index.js +83 -8
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +83 -8
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/lib/article-ui.ts +158 -0
  22. package/dist/lib/auth.ts +354 -0
  23. package/dist/lib/cors.ts +243 -0
  24. package/dist/lib/database.ts +277 -0
  25. package/dist/lib/init.ts +186 -0
  26. package/dist/lib/mdx.ts +73 -0
  27. package/dist/lib/metadata.ts +558 -0
  28. package/dist/lib/ratelimit.ts +224 -0
  29. package/dist/lib/security-headers.ts +322 -0
  30. package/dist/lib/seo.ts +295 -0
  31. package/dist/lib/sitemap.ts +719 -0
  32. package/dist/lib/storage/drizzle.ts +400 -0
  33. package/dist/lib/structured-data.ts +608 -0
  34. package/dist/lib/topics.ts +56 -0
  35. package/dist/lib/urls.ts +29 -0
  36. package/dist/lib/utils.ts +6 -0
  37. package/dist/lib/version.ts +154 -0
  38. package/dist/types/contracts.ts +256 -0
  39. package/dist/types/index.d.mts +18 -1
  40. package/dist/types/index.d.ts +18 -1
  41. package/dist/types/index.ts +16 -0
  42. package/package.json +14 -9
@@ -0,0 +1,236 @@
1
+ import React from "react";
2
+ import { ChevronsLeft, ChevronsRight } from "lucide-react";
3
+ import {
4
+ Pagination as ShadcnPagination,
5
+ PaginationContent,
6
+ PaginationItem,
7
+ PaginationLink,
8
+ PaginationNext,
9
+ PaginationPrevious,
10
+ PaginationEllipsis,
11
+ } from "./ui/pagination";
12
+
13
+ export interface PaginationProps {
14
+ /** Current page number (1-indexed) */
15
+ currentPage: number;
16
+
17
+ /** Total number of pages */
18
+ totalPages: number;
19
+
20
+ /** Base URL for pagination links (e.g., "/blog") */
21
+ baseUrl: string;
22
+
23
+ /** Additional CSS classes */
24
+ className?: string;
25
+
26
+ /** Maximum number of page links to show around current page */
27
+ maxVisiblePages?: number;
28
+
29
+ /** Show/hide previous and next buttons */
30
+ showPrevNext?: boolean;
31
+
32
+ /** Show/hide first and last page links */
33
+ showFirstLast?: boolean;
34
+
35
+ /** Custom labels for navigation */
36
+ labels?: {
37
+ previous?: string;
38
+ next?: string;
39
+ first?: string;
40
+ last?: string;
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Pagination component for blog articles listing.
46
+ *
47
+ * Provides SEO-friendly pagination with proper URL structure,
48
+ * keyboard navigation, and accessibility features.
49
+ */
50
+ export default function Pagination({
51
+ currentPage,
52
+ totalPages,
53
+ baseUrl,
54
+ className,
55
+ maxVisiblePages = 5,
56
+ showPrevNext = true,
57
+ showFirstLast = true,
58
+ }: PaginationProps) {
59
+ // Don't render pagination if there's only one page or no pages
60
+ if (totalPages <= 1) {
61
+ return null;
62
+ }
63
+
64
+ // Calculate visible page range
65
+ const getVisiblePages = () => {
66
+ const half = Math.floor(maxVisiblePages / 2);
67
+ let start = Math.max(1, currentPage - half);
68
+ const end = Math.min(totalPages, start + maxVisiblePages - 1);
69
+
70
+ // Adjust start if we're near the end
71
+ if (end - start + 1 < maxVisiblePages) {
72
+ start = Math.max(1, end - maxVisiblePages + 1);
73
+ }
74
+
75
+ return { start, end };
76
+ };
77
+
78
+ const { start, end } = getVisiblePages();
79
+ const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i);
80
+
81
+ // Generate URL for a specific page
82
+ const getPageUrl = (page: number) => {
83
+ if (page === 1) {
84
+ return baseUrl;
85
+ }
86
+ return `${baseUrl}?page=${page}`;
87
+ };
88
+
89
+ const hasPrevious = currentPage > 1;
90
+ const hasNext = currentPage < totalPages;
91
+
92
+ return (
93
+ <ShadcnPagination className={className}>
94
+ <PaginationContent>
95
+ {/* First Page Link */}
96
+ {showFirstLast && currentPage > 2 && (
97
+ <>
98
+ <PaginationItem>
99
+ <PaginationLink href={getPageUrl(1)} aria-label="Go to first page">
100
+ <ChevronsLeft className="h-4 w-4" />
101
+ </PaginationLink>
102
+ </PaginationItem>
103
+ {start > 2 && (
104
+ <PaginationItem>
105
+ <PaginationEllipsis />
106
+ </PaginationItem>
107
+ )}
108
+ </>
109
+ )}
110
+
111
+ {/* Previous Page Link */}
112
+ {showPrevNext && (
113
+ <PaginationItem>
114
+ {hasPrevious ? (
115
+ <PaginationPrevious href={getPageUrl(currentPage - 1)} />
116
+ ) : (
117
+ <span className="flex h-9 items-center justify-center gap-1 px-2.5 text-muted-foreground sm:pl-2.5">
118
+ <span className="hidden sm:block">Previous</span>
119
+ </span>
120
+ )}
121
+ </PaginationItem>
122
+ )}
123
+
124
+ {/* Page Number Links */}
125
+ {pages.map((page) => (
126
+ <PaginationItem key={page}>
127
+ <PaginationLink
128
+ href={getPageUrl(page)}
129
+ isActive={page === currentPage}
130
+ >
131
+ {page}
132
+ </PaginationLink>
133
+ </PaginationItem>
134
+ ))}
135
+
136
+ {/* Next Page Link */}
137
+ {showPrevNext && (
138
+ <PaginationItem>
139
+ {hasNext ? (
140
+ <PaginationNext href={getPageUrl(currentPage + 1)} />
141
+ ) : (
142
+ <span className="flex h-9 items-center justify-center gap-1 px-2.5 text-muted-foreground sm:pr-2.5">
143
+ <span className="hidden sm:block">Next</span>
144
+ </span>
145
+ )}
146
+ </PaginationItem>
147
+ )}
148
+
149
+ {/* Last Page Link */}
150
+ {showFirstLast && currentPage < totalPages - 1 && (
151
+ <>
152
+ {end < totalPages - 1 && (
153
+ <PaginationItem>
154
+ <PaginationEllipsis />
155
+ </PaginationItem>
156
+ )}
157
+ <PaginationItem>
158
+ <PaginationLink href={getPageUrl(totalPages)} aria-label="Go to last page">
159
+ <ChevronsRight className="h-4 w-4" />
160
+ </PaginationLink>
161
+ </PaginationItem>
162
+ </>
163
+ )}
164
+ </PaginationContent>
165
+
166
+ {/* Screen Reader Info */}
167
+ <div className="sr-only" aria-live="polite">
168
+ Page {currentPage} of {totalPages}
169
+ </div>
170
+ </ShadcnPagination>
171
+ );
172
+ }
173
+
174
+
175
+ /**
176
+ * Hook to calculate pagination metadata from URL parameters.
177
+ * Useful for extracting current page from Next.js searchParams.
178
+ */
179
+ export function usePagination(
180
+ searchParams: Record<string, string | string[] | undefined>,
181
+ itemsPerPage = 10
182
+ ) {
183
+ const pageParam = searchParams.page;
184
+ const pageString = Array.isArray(pageParam) ? pageParam[0] : pageParam;
185
+ const currentPage = Math.max(1, parseInt(pageString ?? "1", 10));
186
+
187
+ const offset = (currentPage - 1) * itemsPerPage;
188
+
189
+ const calculateTotalPages = (totalItems: number) => {
190
+ return Math.ceil(totalItems / itemsPerPage);
191
+ };
192
+
193
+ return {
194
+ currentPage,
195
+ itemsPerPage,
196
+ offset,
197
+ calculateTotalPages
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Generate pagination metadata for SEO.
203
+ * Provides canonical URLs, next/prev relationships for search engines.
204
+ */
205
+ export function generatePaginationMetadata(
206
+ currentPage: number,
207
+ totalPages: number,
208
+ baseUrl: string,
209
+ siteUrl?: string
210
+ ) {
211
+ const canonicalUrl = siteUrl
212
+ ? `${siteUrl}${currentPage === 1 ? baseUrl : `${baseUrl}?page=${currentPage}`}`
213
+ : undefined;
214
+
215
+ const prevUrl = currentPage > 1
216
+ ? siteUrl
217
+ ? `${siteUrl}${currentPage === 2 ? baseUrl : `${baseUrl}?page=${currentPage - 1}`}`
218
+ : undefined
219
+ : undefined;
220
+
221
+ const nextUrl = currentPage < totalPages
222
+ ? siteUrl
223
+ ? `${siteUrl}${baseUrl}?page=${currentPage + 1}`
224
+ : undefined
225
+ : undefined;
226
+
227
+ return {
228
+ alternates: {
229
+ ...(canonicalUrl && { canonical: canonicalUrl }),
230
+ },
231
+ other: {
232
+ ...(prevUrl && { prev: prevUrl }),
233
+ ...(nextUrl && { next: nextUrl }),
234
+ },
235
+ };
236
+ }
@@ -0,0 +1,204 @@
1
+ import React from "react";
2
+ import Link from "next/link";
3
+ import Image from "next/image";
4
+ import { formatDistanceToNow } from "date-fns";
5
+
6
+ import type { PublishedArticle, PublishedTopic } from "../types/contracts";
7
+ import type { BlogTheme } from "../lib/article-ui";
8
+ import { getBlogThemeStyle } from "../lib/article-ui";
9
+ import { buildBlogPath, normalizeBlogBasePath } from "../lib/urls";
10
+ import { cn } from "../lib/utils";
11
+ import { getAllTopics, getTopicArchive } from "../lib/topics";
12
+
13
+ interface BlogTopicsIndexPageProps {
14
+ articles: PublishedArticle[];
15
+ basePath?: string;
16
+ title?: string;
17
+ description?: string;
18
+ className?: string;
19
+ theme?: BlogTheme;
20
+ }
21
+
22
+ interface BlogTopicArchivePageProps {
23
+ articles: PublishedArticle[];
24
+ topicSlug: string;
25
+ basePath?: string;
26
+ className?: string;
27
+ theme?: BlogTheme;
28
+ }
29
+
30
+ export function BlogTopicsIndexPage({
31
+ articles,
32
+ basePath = "/blog",
33
+ title = "Topics",
34
+ description = "Browse articles by topic.",
35
+ className,
36
+ theme,
37
+ }: BlogTopicsIndexPageProps) {
38
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
39
+ const topics = getAllTopics(articles);
40
+
41
+ return (
42
+ <main
43
+ style={getBlogThemeStyle(theme)}
44
+ className={cn(
45
+ "blog-topics-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
46
+ className,
47
+ )}
48
+ >
49
+ <header className="mb-10 space-y-3 text-center">
50
+ <Link
51
+ href={normalizedBasePath}
52
+ className="text-muted-foreground hover:text-[var(--blog-accent)] text-sm font-medium transition-colors"
53
+ >
54
+ Back to all articles
55
+ </Link>
56
+ <h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
57
+ {title}
58
+ </h1>
59
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
60
+ {description}
61
+ </p>
62
+ </header>
63
+
64
+ {topics.length === 0 ? (
65
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
66
+ No topics have published articles yet.
67
+ </p>
68
+ ) : (
69
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
70
+ {topics.map((topic) => {
71
+ const count = articles.filter((article) =>
72
+ (article.topics ?? []).some(
73
+ (articleTopic) => articleTopic.slug === topic.slug,
74
+ ),
75
+ ).length;
76
+
77
+ return (
78
+ <Link
79
+ key={topic.slug}
80
+ href={buildBlogPath(normalizedBasePath, `topics/${topic.slug}`)}
81
+ className="group rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] p-5 transition hover:border-[var(--blog-accent)] hover:no-underline"
82
+ >
83
+ <h2 className="group-hover:text-[var(--blog-accent)] text-xl font-semibold transition-colors">
84
+ {topic.name}
85
+ </h2>
86
+ {topic.description && (
87
+ <p className="text-muted-foreground mt-2 line-clamp-3 text-sm leading-relaxed">
88
+ {topic.description}
89
+ </p>
90
+ )}
91
+ <p className="text-muted-foreground mt-4 text-sm">
92
+ {count} {count === 1 ? "article" : "articles"}
93
+ </p>
94
+ </Link>
95
+ );
96
+ })}
97
+ </div>
98
+ )}
99
+ </main>
100
+ );
101
+ }
102
+
103
+ export function BlogTopicArchivePage({
104
+ articles,
105
+ topicSlug,
106
+ basePath = "/blog",
107
+ className,
108
+ theme,
109
+ }: BlogTopicArchivePageProps) {
110
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
111
+ const archive = getTopicArchive(articles, topicSlug);
112
+
113
+ return (
114
+ <main
115
+ style={getBlogThemeStyle(theme)}
116
+ className={cn(
117
+ "blog-topic-archive-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
118
+ className,
119
+ )}
120
+ >
121
+ <header className="mb-10 space-y-3 text-center">
122
+ <Link
123
+ href={buildBlogPath(normalizedBasePath, "topics")}
124
+ className="text-muted-foreground hover:text-[var(--blog-accent)] text-sm font-medium transition-colors"
125
+ >
126
+ Back to all topics
127
+ </Link>
128
+ <h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
129
+ {archive?.topic.name ?? "Topic not found"}
130
+ </h1>
131
+ {archive?.topic.description && (
132
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
133
+ {archive.topic.description}
134
+ </p>
135
+ )}
136
+ </header>
137
+
138
+ {!archive ? (
139
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
140
+ No published articles were found for this topic.
141
+ </p>
142
+ ) : (
143
+ <div className="grid gap-5 md:grid-cols-2">
144
+ {archive.articles.map((article) => (
145
+ <TopicArticleCard
146
+ key={article.id}
147
+ article={article}
148
+ topic={archive.topic}
149
+ basePath={normalizedBasePath}
150
+ />
151
+ ))}
152
+ </div>
153
+ )}
154
+ </main>
155
+ );
156
+ }
157
+
158
+ function TopicArticleCard({
159
+ article,
160
+ topic,
161
+ basePath,
162
+ }: {
163
+ article: PublishedArticle;
164
+ topic: PublishedTopic;
165
+ basePath: string;
166
+ }) {
167
+ const articleUrl = buildBlogPath(basePath, article.slug);
168
+ const publishedAgo = formatDistanceToNow(new Date(article.publishedAt), {
169
+ addSuffix: true,
170
+ });
171
+
172
+ return (
173
+ <Link
174
+ href={articleUrl}
175
+ className="group overflow-hidden rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] transition hover:border-[var(--blog-accent)] hover:no-underline"
176
+ >
177
+ {article.featuredImage && (
178
+ <div className="relative aspect-[16/9] overflow-hidden">
179
+ <Image
180
+ src={article.featuredImage}
181
+ alt={article.featuredImageAlt ?? article.title}
182
+ width={640}
183
+ height={360}
184
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
185
+ />
186
+ </div>
187
+ )}
188
+ <div className="space-y-3 p-5">
189
+ <span className="inline-flex rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]">
190
+ {topic.name}
191
+ </span>
192
+ <h2 className="group-hover:text-[var(--blog-accent)] text-xl font-semibold text-balance transition-colors">
193
+ {article.title}
194
+ </h2>
195
+ {article.excerpt && (
196
+ <p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed">
197
+ {article.excerpt}
198
+ </p>
199
+ )}
200
+ <p className="text-muted-foreground text-sm">{publishedAgo}</p>
201
+ </div>
202
+ </Link>
203
+ );
204
+ }
@@ -0,0 +1,36 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "../../lib/utils"
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13
+ secondary:
14
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ destructive:
16
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17
+ outline: "text-foreground",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ },
23
+ }
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends React.HTMLAttributes<HTMLDivElement>,
28
+ VariantProps<typeof badgeVariants> {}
29
+
30
+ function Badge({ className, variant, ...props }: BadgeProps) {
31
+ return (
32
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
33
+ )
34
+ }
35
+
36
+ export { Badge, badgeVariants }
@@ -0,0 +1,59 @@
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "../../lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16
+ outline:
17
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20
+ ghost:
21
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22
+ link: "text-primary underline-offset-4 hover:underline",
23
+ },
24
+ size: {
25
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
26
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
+ icon: "size-9",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: "default",
33
+ size: "default",
34
+ },
35
+ }
36
+ )
37
+
38
+ function Button({
39
+ className,
40
+ variant,
41
+ size,
42
+ asChild = false,
43
+ ...props
44
+ }: React.ComponentProps<"button"> &
45
+ VariantProps<typeof buttonVariants> & {
46
+ asChild?: boolean
47
+ }) {
48
+ const Comp = asChild ? Slot : "button"
49
+
50
+ return (
51
+ <Comp
52
+ data-slot="button"
53
+ className={cn(buttonVariants({ variant, size, className }))}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ export { Button, buttonVariants }
@@ -0,0 +1,92 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../../lib/utils"
4
+
5
+ function Card({ className, ...props }: React.ComponentProps<"div">) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19
+ return (
20
+ <div
21
+ data-slot="card-header"
22
+ className={cn(
23
+ "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
24
+ className
25
+ )}
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32
+ return (
33
+ <div
34
+ data-slot="card-title"
35
+ className={cn("leading-none font-semibold", className)}
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42
+ return (
43
+ <div
44
+ data-slot="card-description"
45
+ className={cn("text-muted-foreground text-sm", className)}
46
+ {...props}
47
+ />
48
+ )
49
+ }
50
+
51
+ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52
+ return (
53
+ <div
54
+ data-slot="card-action"
55
+ className={cn(
56
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
57
+ className
58
+ )}
59
+ {...props}
60
+ />
61
+ )
62
+ }
63
+
64
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65
+ return (
66
+ <div
67
+ data-slot="card-content"
68
+ className={cn("px-6", className)}
69
+ {...props}
70
+ />
71
+ )
72
+ }
73
+
74
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75
+ return (
76
+ <div
77
+ data-slot="card-footer"
78
+ className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
79
+ {...props}
80
+ />
81
+ )
82
+ }
83
+
84
+ export {
85
+ Card,
86
+ CardHeader,
87
+ CardFooter,
88
+ CardTitle,
89
+ CardAction,
90
+ CardDescription,
91
+ CardContent,
92
+ }