diff --git a/content-collections.ts b/content-collections.ts index a89fec7..c29130b 100644 --- a/content-collections.ts +++ b/content-collections.ts @@ -68,6 +68,7 @@ export const posts = defineCollection({ schema: (z) => ({ title: z.string(), description: z.string(), + image: z.string(), date: z.string().datetime(), published: z.boolean().default(true), categories: z.array(z.string()), @@ -95,6 +96,7 @@ export const posts = defineCollection({ ...data, author: blogAuthor, categories: blogCategories, + image: getBaseUrl() + data.image, slug: `/${data._meta.path}`, slugAsParams: data._meta.path.split(path.sep).slice(1).join('/'), body: { diff --git a/next.config.ts b/next.config.ts index 3d6e341..06bb143 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,44 @@ import { withContentCollections } from "@content-collections/next"; const nextConfig: NextConfig = { /* config options here */ + + // https://nextjs.org/docs/architecture/nextjs-compiler#remove-console + // Remove all console.* calls in production only + compiler: { + removeConsole: process.env.NODE_ENV === "production", + }, + + images: { + // https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs + // vercel has limits on image optimization, 1000 images per month + unoptimized: true, + // https://medium.com/@niniroula/nextjs-upgrade-next-image-and-dangerouslyallowsvg-c934060d79f8 + // The requested resource "https://cdn.sanity.io/images/58a2mkbj/preview/xxx.svg?fit=max&auto=format" has type "image/svg+xml" + // but dangerouslyAllowSVG is disabled + dangerouslyAllowSVG: true, + remotePatterns: [ + { + protocol: "https", + hostname: "avatars.githubusercontent.com", + }, + { + protocol: "https", + hostname: "lh3.googleusercontent.com", + }, + { + protocol: "https", + hostname: "randomuser.me", + }, + { + protocol: "https", + hostname: "cdn.sanity.io", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end + }, + { + protocol: "https", + hostname: "via.placeholder.com", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end + }, + ], + }, }; // https://www.content-collections.dev/docs/quickstart/next diff --git a/package.json b/package.json index bf9f1df..cbe94b5 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dotenv": "^16.4.7", "drizzle-orm": "^0.39.3", "lucide-react": "^0.475.0", + "mdast-util-toc": "^7.1.0", "motion": "^12.4.3", "next": "15.1.7", "next-plausible": "^3.12.4", @@ -46,6 +47,7 @@ "rehype-autolink-headings": "^7.1.0", "rehype-pretty-code": "^0.14.0", "rehype-slug": "^6.0.0", + "remark": "^15.0.1", "remark-code-import": "^1.2.0", "remark-gfm": "^4.0.1", "resend": "^4.1.2", @@ -54,6 +56,7 @@ "stripe": "^17.6.0", "tailwind-merge": "^3.0.1", "tailwindcss-animate": "^1.0.7", + "unist-util-visit": "^5.0.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec94f4d..a084961 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: lucide-react: specifier: ^0.475.0 version: 0.475.0(react@19.0.0) + mdast-util-toc: + specifier: ^7.1.0 + version: 7.1.0 motion: specifier: ^12.4.3 version: 12.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -119,6 +122,9 @@ importers: rehype-slug: specifier: ^6.0.0 version: 6.0.0 + remark: + specifier: ^15.0.1 + version: 15.0.1 remark-code-import: specifier: ^1.2.0 version: 1.2.0 @@ -143,6 +149,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17) + unist-util-visit: + specifier: ^5.0.0 + version: 5.0.0 zod: specifier: ^3.24.2 version: 3.24.2 @@ -1785,6 +1794,9 @@ packages: '@types/text-table@0.2.5': resolution: {integrity: sha512-hcZhlNvMkQG/k1vcZ6yHOl6WAYftQ2MLfTHcYRZ2xYZFD8tGVnE3qFV0lj1smQeDSR7/yY0PyuUalauf33bJeA==} + '@types/ungap__structured-clone@1.2.0': + resolution: {integrity: sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2625,6 +2637,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdast-util-toc@7.1.0: + resolution: {integrity: sha512-2TVKotOQzqdY7THOdn2gGzS9d1Sdd66bvxUyw3aNpWfcPXCLYSJCCgfPy30sEtuzkDraJgqF35dzgmz6xlvH/w==} + mdx-bundler@10.1.0: resolution: {integrity: sha512-HtyVyqzBz/rG3hjca2ZggI5Ghx8YdBeAfnDIgqiMVOZyum1WcmBsdPKYN0AhV4SNaxyohASIrwC8DiXtU55FUA==} engines: {node: '>=18', npm: '>=6'} @@ -3206,6 +3221,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4945,6 +4963,8 @@ snapshots: '@types/text-table@0.2.5': {} + '@types/ungap__structured-clone@1.2.0': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -5951,6 +5971,16 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdast-util-toc@7.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/ungap__structured-clone': 1.2.0 + '@ungap/structured-clone': 1.3.0 + github-slugger: 2.0.0 + mdast-util-to-string: 4.0.0 + unist-util-is: 6.0.0 + unist-util-visit: 5.0.0 + mdx-bundler@10.1.0(acorn@8.14.0)(esbuild@0.21.5): dependencies: '@babel/runtime': 7.26.9 @@ -6741,6 +6771,15 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + require-directory@2.1.1: {} resend@4.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0): diff --git a/src/app/(marketing)/blog/(blog)/category/[slug]/loading.tsx b/src/app/(marketing)/blog/(blog)/category/[slug]/loading.tsx new file mode 100644 index 0000000..029f260 --- /dev/null +++ b/src/app/(marketing)/blog/(blog)/category/[slug]/loading.tsx @@ -0,0 +1,5 @@ +import { BlogGridSkeleton } from "@/components/blog/blog-grid"; + +export default function Loading() { + return ; +} diff --git a/src/app/(marketing)/blog/(blog)/layout.tsx b/src/app/(marketing)/blog/(blog)/layout.tsx new file mode 100644 index 0000000..4c87061 --- /dev/null +++ b/src/app/(marketing)/blog/(blog)/layout.tsx @@ -0,0 +1,24 @@ +import Container from "@/components/container"; +import { HeaderSection } from "@/components/shared/header-section"; + +export default async function BlogListLayout({ + children, +}: { children: React.ReactNode }) { + return ( +
+
+ + + {/* */} +
+ + {children} +
+ ); +} diff --git a/src/app/(marketing)/blog/(blog)/loading.tsx b/src/app/(marketing)/blog/(blog)/loading.tsx new file mode 100644 index 0000000..029f260 --- /dev/null +++ b/src/app/(marketing)/blog/(blog)/loading.tsx @@ -0,0 +1,5 @@ +import { BlogGridSkeleton } from "@/components/blog/blog-grid"; + +export default function Loading() { + return ; +} diff --git a/src/app/(marketing)/blog/(blog)/page.tsx b/src/app/(marketing)/blog/(blog)/page.tsx new file mode 100644 index 0000000..d08540b --- /dev/null +++ b/src/app/(marketing)/blog/(blog)/page.tsx @@ -0,0 +1,61 @@ +import BlogGrid from "@/components/blog/blog-grid"; +import EmptyGrid from "@/components/shared/empty-grid"; +import CustomPagination from "@/components/shared/pagination"; +import { siteConfig } from "@/config/site"; +import { POSTS_PER_PAGE } from "@/lib/constants"; +import { constructMetadata } from "@/lib/metadata"; +import { NextPageProps } from "@/types/next-page-props"; +import { allPosts } from "content-collections"; + +export const metadata = constructMetadata({ + title: "Blog", + description: "Read our latest blog posts", + canonicalUrl: `${siteConfig.url}/blog`, +}); + +export default async function BlogIndexPage(props: NextPageProps) { + const searchParams = await props.searchParams; + console.log("BlogIndexPage, searchParams", searchParams); + const page = typeof searchParams?.page === 'string' ? Number(searchParams.page) : 1; + const posts = await Promise.all( + allPosts + .filter((post) => post.published) + .sort((a, b) => b.date.localeCompare(a.date)) + .slice( (page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE, ) + // .map(async (post) => ({ + // ...post, + // blurDataURL: await getBlurDataURL(post.image), + // })), + ); + // const posts = allPosts.slice( + // (currentPage - 1) * POSTS_PER_PAGE, + // currentPage * POSTS_PER_PAGE, + // ); + const totalCount = allPosts.length; + const totalPages = Math.ceil(totalCount / POSTS_PER_PAGE); + + console.log( + "BlogIndexPage, totalCount", + totalCount, + ", totalPages", + totalPages, + ); + + return ( +
+ {/* when no posts are found */} + {posts?.length === 0 && } + + {/* when posts are found */} + {posts && posts?.length > 0 && ( +
+ + +
+ +
+
+ )} +
+ ); +} diff --git a/src/app/(marketing)/blog/[...slug]/layout.tsx b/src/app/(marketing)/blog/[...slug]/layout.tsx new file mode 100644 index 0000000..1527603 --- /dev/null +++ b/src/app/(marketing)/blog/[...slug]/layout.tsx @@ -0,0 +1,17 @@ +import Container from "@/components/container"; +import { NewsletterCard } from "@/components/newsletter/newsletter-card"; +import type React from "react"; + +export default function BlogPostLayout({ + children, +}: { children: React.ReactNode }) { + return ( +
+ {children} + + + + +
+ ); +} diff --git a/src/app/(marketing)/blog/[...slug]/loading.tsx b/src/app/(marketing)/blog/[...slug]/loading.tsx new file mode 100644 index 0000000..75201d0 --- /dev/null +++ b/src/app/(marketing)/blog/[...slug]/loading.tsx @@ -0,0 +1,82 @@ +import { BlogGridSkeleton } from "@/components/blog/blog-grid"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ {/* Content section */} +
+ {/* Left column */} +
+ {/* Basic information */} +
+ {/* blog post image */} + + + {/* blog post title */} + + + {/* blog post description */} + +
+ + {/* blog post content */} +
+ +
+ +
+ {/* */} +
+
+ + {/* Right column (sidebar) */} +
+
+ {/* author info */} +
+ +
+ +
+ + +
+
+
+ + {/* categories */} +
+ +
+ {[...Array(3)].map((_, index) => ( + + ))} +
+
+ + {/* table of contents */} +
+ +
+ {[...Array(5)].map((_, index) => ( + + ))} +
+
+
+
+
+ + {/* Footer section shows related posts */} +
+
+ + +
+ + +
+
+ ); +} diff --git a/src/app/(marketing)/blog/[...slug]/page.tsx b/src/app/(marketing)/blog/[...slug]/page.tsx index cc3bf71..1867aa4 100644 --- a/src/app/(marketing)/blog/[...slug]/page.tsx +++ b/src/app/(marketing)/blog/[...slug]/page.tsx @@ -4,8 +4,16 @@ import { allPosts } from 'content-collections'; import { BlogPost } from '@/components/blog/blog-post'; import { getBaseUrl } from '@/lib/urls/get-base-url'; import type { NextPageProps } from '@/types/next-page-props'; +import BlogGrid from '@/components/blog/blog-grid'; +import { getTableOfContents } from '@/lib/toc'; +import { getLocaleDate } from '@/lib/utils'; +import Link from 'next/link'; +import Image from 'next/image'; +import { BlogToc } from '@/components/blog/blog-toc'; +import { Mdx } from '@/components/marketing/blog/mdx-component'; import '@/app/mdx.css'; +import AllPostsButton from '@/components/blog/all-posts-button'; /** * Gets the blog post from the params @@ -71,5 +79,124 @@ export default async function BlogPostPage(props: NextPageProps) { if (!post) { return notFound(); } - return ; + // return ; + + // console.log("PostPage, post", post); + // const imageProps = post?.image ? urlForImage(post?.image) : null; + // const imageBlurDataURL = post?.image?.blurDataURL || null; + const publishDate = post.date; + const date = getLocaleDate(publishDate); + // const markdownContent = portableTextToMarkdown(post.body); + // console.log("markdownContent", markdownContent); + + const toc = await getTableOfContents(post.content); + + return ( +
+ {/* Content section */} +
+ {/* Left column */} +
+ {/* Basic information */} +
+ {/* blog post image */} +
+ {post.image && ( + {post.title + )} +
+ + {/* blog post title */} +

{post.title}

+ + {/* blog post description */} +

{post.description}

+
+ + {/* blog post content */} +
+ + {/* {markdownContent && } */} +
+ +
+ +
+
+ + {/* Right column (sidebar) */} +
+
+ {/* author info */} +
+

Publisher

+
+
+ {post.author?.avatar && ( + {`avatar + )} +
+
+ {post.author?.name} + +

{date}

+
+
+
+ + {/* categories */} +
+

Categories

+
    + {post.categories?.map((category) => ( +
  • + + {category.name} + +
  • + ))} +
+
+ + {/* table of contents */} +
+

Table of Contents

+
+ +
+
+
+
+
+ + {/* Footer section shows related posts */} + {/* {post.relatedPosts && post.relatedPosts.length > 0 && ( +
+
+ +

+ More Posts +

+
+ + +
+ )} */} +
+ ); } diff --git a/src/app/(marketing)/blog/page.tsx b/src/app/(marketing)/blog/page.tsx deleted file mode 100644 index d71638d..0000000 --- a/src/app/(marketing)/blog/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react'; -import type { Metadata } from 'next'; -import { BlogPosts } from '@/components/blog/blog-posts'; -import { createTitle } from '@/lib/utils'; - -export const metadata: Metadata = { - title: createTitle('Blog') -}; - -export default function BlogPage(): React.JSX.Element { - return ; -} diff --git a/src/components/blog/all-posts-button.tsx b/src/components/blog/all-posts-button.tsx new file mode 100644 index 0000000..2b5e4ee --- /dev/null +++ b/src/components/blog/all-posts-button.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; + +export default function AllPostsButton() { + return ( + + ); +} diff --git a/src/components/blog/blog-card.tsx b/src/components/blog/blog-card.tsx new file mode 100644 index 0000000..a246620 --- /dev/null +++ b/src/components/blog/blog-card.tsx @@ -0,0 +1,121 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { getBaseUrl } from "@/lib/urls/get-base-url"; +import { getLocaleDate } from "@/lib/utils"; +import { Post } from "content-collections"; +import Image from "next/image"; +import Link from "next/link"; + +type BlogCardProps = { + post: Post; +}; + +export default function BlogCard({ post }: BlogCardProps) { + const publishDate = post.date; + const date = getLocaleDate(publishDate); + // const postUrlPrefix = "/blog"; + const postUrlPrefix = getBaseUrl(); + const postUrl = `${postUrlPrefix}${post.slug}`; + + return ( +
+ {/* Image container */} +
+ + {post.image && ( +
+ {post.title + + {post.categories && post.categories.length > 0 && ( +
+
+ {post.categories.map((category, index) => ( + + {category.name} + + ))} +
+
+ )} +
+ )} + +
+ + {/* Post info container */} +
+
+ {/* Post title */} +

+ + + {post.title} + + +

+ + {/* Post excerpt, hidden for now */} +
+ {post.description && ( +

+ {post.description} +

+ )} +
+
+ + {/* Author and date */} +
+
+
+ {post?.author?.avatar && ( + {`avatar + )} +
+ {post?.author?.name} +
+ + +
+
+
+ ); +} + +export function BlogCardSkeleton() { + return ( +
+
+ +
+ +
+ + +
+
+ ); +} diff --git a/src/components/blog/blog-grid.tsx b/src/components/blog/blog-grid.tsx new file mode 100644 index 0000000..0deaf94 --- /dev/null +++ b/src/components/blog/blog-grid.tsx @@ -0,0 +1,34 @@ +import BlogCard, { BlogCardSkeleton } from "@/components/blog/blog-card"; +import { POSTS_PER_PAGE } from "@/lib/constants"; +import { Post } from "content-collections"; + +interface BlogGridProps { + posts: Post[]; +} + +export default function BlogGrid({ posts }: BlogGridProps) { + // console.log('BlogGrid, posts', posts); + return ( +
+ {posts?.length > 0 && ( +
+ {posts.map((post) => ( + + ))} +
+ )} +
+ ); +} + +export function BlogGridSkeleton({ + count = POSTS_PER_PAGE, +}: { count?: number }) { + return ( +
+ {[...Array(count)].map((_, index) => ( + + ))} +
+ ); +} diff --git a/src/components/blog/blog-toc.tsx b/src/components/blog/blog-toc.tsx new file mode 100644 index 0000000..2fe3889 --- /dev/null +++ b/src/components/blog/blog-toc.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useMounted } from "@/hooks/use-mounted"; +import type { TableOfContents } from "@/lib/toc"; +import { cn } from "@/lib/utils"; +import * as React from "react"; + +interface TocProps { + toc: TableOfContents; +} + +export function BlogToc({ toc }: TocProps) { + const itemIds = React.useMemo( + () => + toc.items + ? toc.items + .flatMap((item) => [item.url, item?.items?.map((item) => item.url)]) + .flat() + .filter(Boolean) + .map((id) => id?.split("#")[1]) + : [], + [toc], + ); + const activeHeading = useActiveItem(itemIds); + const mounted = useMounted(); + + if (!toc?.items) { + return null; + } + + return mounted ? ( +
+ +
+ ) : null; +} + +function useActiveItem(itemIds: (string | undefined)[]) { + const [activeId, setActiveId] = React.useState(""); + + React.useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + } + }, + { rootMargin: "0% 0% -80% 0%" }, + ); + + for (const id of itemIds) { + if (!id) { + continue; + } + + const element = document.getElementById(id); + if (element) { + observer.observe(element); + } + } + + return () => { + for (const id of itemIds) { + if (!id) { + continue; + } + + const element = document.getElementById(id); + if (element) { + observer.unobserve(element); + } + } + }; + }, [itemIds]); + + return activeId; +} + +interface TreeProps { + tree: TableOfContents; + level?: number; + activeItem?: string | null; +} + +function Tree({ tree, level = 1, activeItem }: TreeProps) { + return tree?.items?.length && level < 3 ? ( +
    + {tree.items.map((item, index) => { + return ( +
  • + + {item.title} + + {item.items?.length ? ( + + ) : null} +
  • + ); + })} +
+ ) : null; +} diff --git a/src/components/newsletter/newsletter-card.tsx b/src/components/newsletter/newsletter-card.tsx new file mode 100644 index 0000000..9a24c64 --- /dev/null +++ b/src/components/newsletter/newsletter-card.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { HeaderSection } from "@/components/shared/header-section"; +import { NewsletterForm } from "@/components/newsletter/newsletter-form"; + +export function NewsletterCard() { + return ( +
+
+ + + +
+
+ ); +} diff --git a/src/components/newsletter/newsletter-form.tsx b/src/components/newsletter/newsletter-form.tsx new file mode 100644 index 0000000..b3199a2 --- /dev/null +++ b/src/components/newsletter/newsletter-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +// import { subscribeToNewsletter } from "@/actions/subscribe-to-newsletter"; +import { Icons } from "@/components/icons/icons"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { type NewsletterFormData, NewsletterFormSchema } from "@/lib/schemas"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { PaperPlaneIcon } from "@radix-ui/react-icons"; +import { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +export function NewsletterForm() { + const [isPending, startTransition] = useTransition(); + + const form = useForm({ + resolver: zodResolver(NewsletterFormSchema), + defaultValues: { + email: "", + }, + }); + + function onSubmit(data: NewsletterFormData) { + // startTransition(async () => { + // subscribeToNewsletter({ email: data.email }) + // .then((data) => { + // switch (data.status) { + // case "success": + // toast.success("Thank you for subscribing to our newsletter"); + // form.reset(); + // break; + // default: + // toast.error("Something went wrong, please try again"); + // } + // }) + // .catch((error) => { + // console.error("NewsletterForm, onSubmit, error:", error); + // toast.error("Something went wrong"); + // }); + // }); + } + + return ( +
+ + ( + + Email + + + + + + )} + /> + + + + ); +} diff --git a/src/components/shared/callout.tsx b/src/components/shared/callout.tsx new file mode 100644 index 0000000..8d4a2bc --- /dev/null +++ b/src/components/shared/callout.tsx @@ -0,0 +1,86 @@ +import { + AlertTriangle, + Ban, + CircleAlert, + CircleCheckBig, + FileText, + Info, + Lightbulb, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; + +interface CalloutProps { + twClass?: string; + children?: React.ReactNode; + type?: keyof typeof dataCallout; +} + +const dataCallout = { + default: { + icon: Info, + classes: + "border-zinc-200 bg-gray-50 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200", + }, + danger: { + icon: CircleAlert, + classes: + "border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200", + }, + error: { + icon: Ban, + classes: + "border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200", + }, + idea: { + icon: Lightbulb, + classes: + "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", + }, + info: { + icon: Info, + classes: + "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", + }, + note: { + icon: FileText, + classes: + "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", + }, + success: { + icon: CircleCheckBig, + classes: + "border-green-200 bg-green-50 text-green-800 dark:bg-green-400/20 dark:text-green-300", + }, + warning: { + icon: AlertTriangle, + classes: + "border-orange-200 bg-orange-50 text-orange-800 dark:bg-orange-400/20 dark:text-orange-300", + }, +}; + +export function Callout({ + children, + twClass, + type = "default", + ...props +}: CalloutProps) { + const { icon: Icon, classes } = dataCallout[type]; + return ( +
+ {/*
+
+ +
+
{children}
+
*/} +
+ ); +} diff --git a/src/components/shared/copy-button.tsx b/src/components/shared/copy-button.tsx new file mode 100644 index 0000000..f1ef284 --- /dev/null +++ b/src/components/shared/copy-button.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import React, { useEffect } from "react"; + +interface CopyButtonProps extends React.HTMLAttributes { + value: string; +} + +export function CopyButton({ value, className, ...props }: CopyButtonProps) { + const [hasCopied, setHasCopied] = React.useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + setTimeout(() => { + setHasCopied(false); + }, 2000); + }, [hasCopied]); + + const handleCopyValue = (value: string) => { + navigator.clipboard.writeText(value); + setHasCopied(true); + }; + + return ( + + ); +} diff --git a/src/components/shared/empty-grid.tsx b/src/components/shared/empty-grid.tsx new file mode 100644 index 0000000..30bfd53 --- /dev/null +++ b/src/components/shared/empty-grid.tsx @@ -0,0 +1,9 @@ +export default function EmptyGrid() { + return ( +
+
+

Nothing found.

+
+
+ ); +} diff --git a/src/components/shared/header-section.tsx b/src/components/shared/header-section.tsx new file mode 100644 index 0000000..11f8455 --- /dev/null +++ b/src/components/shared/header-section.tsx @@ -0,0 +1,56 @@ +import { cn } from "@/lib/utils"; + +interface HeaderSectionProps { + id?: string; + label?: string; + labelAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"; + title?: string; + titleAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"; + subtitle?: string; + subtitleAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"; + className?: string; + children?: React.ReactNode; +} + +/** + * different pages may use this component as different heading style for SEO friendly + */ +export function HeaderSection({ + id, + label, + labelAs = "p", + title, + titleAs = "p", + subtitle, + subtitleAs = "p", + className, + children, +}: HeaderSectionProps) { + const LabelComponent = labelAs; + const TitleComponent = titleAs; + const SubtitleComponent = subtitleAs; + return ( +
+ {label ? ( + + {label} + + ) : null} + {title ? ( + + {title} + + ) : null} + {subtitle ? ( + + {subtitle} + + ) : null} + + {children} +
+ ); +} diff --git a/src/components/shared/pagination.tsx b/src/components/shared/pagination.tsx new file mode 100644 index 0000000..af1fac0 --- /dev/null +++ b/src/components/shared/pagination.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { useRouter, useSearchParams } from "next/navigation"; + +type CustomPaginationProps = { + totalPages: number; + routePreix: string; +}; + +export default function CustomPagination({ + totalPages, + routePreix, +}: CustomPaginationProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const currentPage = Number(searchParams.get("page")) || 1; + + const handlePageChange = (page: number | string) => { + const params = new URLSearchParams(searchParams); + params.set("page", page.toString()); + router.push(`${routePreix}?${params.toString()}`); + }; + + const allPages = generatePagination(currentPage, totalPages); + + return ( + + + + 1 + ? () => handlePageChange(currentPage - 1) + : undefined + } + aria-disabled={currentPage <= 1} + className={ + currentPage <= 1 + ? "pointer-events-none text-gray-300 dark:text-gray-600" + : "cursor-pointer" + } + /> + + {allPages.map((page, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + {page === "..." ? ( + + ) : ( + handlePageChange(page)} + isActive={currentPage === page} + className={currentPage === page ? "" : "cursor-pointer"} + > + {page} + + )} + + ))} + + + handlePageChange(currentPage + 1) + : undefined + } + aria-disabled={currentPage >= totalPages} + className={ + currentPage >= totalPages + ? "pointer-events-none text-gray-300 dark:text-gray-600" + : "cursor-pointer" + } + /> + + + + ); +} + +/** + * Generate an array of page numbers to display in the pagination component + */ +const generatePagination = (currentPage: number, totalPages: number) => { + // If the total number of pages is 7 or less, + // display all pages without any ellipsis. + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + // If the current page is among the first 3 pages, + // show the first 3, an ellipsis, and the last 2 pages. + if (currentPage <= 3) { + return [1, 2, 3, "...", totalPages - 1, totalPages]; + } + + // If the current page is among the last 3 pages, + // show the first 2, an ellipsis, and the last 3 pages. + if (currentPage >= totalPages - 2) { + return [1, 2, "...", totalPages - 2, totalPages - 1, totalPages]; + } + + // If the current page is somewhere in the middle, + // show the first page, an ellipsis, the current page and its neighbors, + // another ellipsis, and the last page. + return [ + 1, + "...", + currentPage - 1, + currentPage, + currentPage + 1, + "...", + totalPages, + ]; +}; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..ea40d19 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +