@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.
- package/CHANGELOG.md +7 -0
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +155 -113
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +59 -69
- package/dist/index.d.ts +59 -69
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +719 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +608 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +29 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +256 -0
- package/dist/types/index.d.mts +18 -1
- package/dist/types/index.d.ts +18 -1
- package/dist/types/index.ts +16 -0
- 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
|
+
}
|