Creating a Human vs AI Auto Blogger - Part 2 - Building UI with Next Js, Tailwind, & Shadcn UI

Creating a Saas Auto Blogger - Part 2 - Building UI with Next Js, Tailwind, & Shadcn UI
** Please see Part 1 before proceeding **
Part 2 - Let’s stylize the pages using Shadcn!
For this part, it is all about styling our blog, let’s head over to Shadcn website and install the components we will need. If you don’t know Shadcn, it’s a react component library built atop radix that allows easy installation and disposal. We first initiate, then add the components we need.
npx shadcn-ui@latest init
Select the following in terminal:
$ npx shadcn-ui@latest init
√ Which style would you like to use? » Default
√ Which color would you like to use as base color? » Slate
√ Would you like to use CSS variables for colors? ... no / yes
✔ Writing components.json...
✔ Initializing project...
✔ Installing dependencies...
Success! Project initialization completed. You may now add components.
Next, add let’s add the components we will need for later:
npx shadcn-ui@latest add button avatar card dropdown-menu separator sheet sonner
With that installed, let’s add some accessibility, ie add Dark mode / Light mode toggle. We’ll install next themes:
npm i next-themes
Then we need a provider for the theme, add a providers folder and a theme-provider.tsx to src/providers/theme-provider.tsx with this code:
//src/providers/theme-provider.tsx
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
And the toggle switch to src/components/global/mode-toggle.tsx
//src/components/global/mode-toggle.tsx
'use client'
import * as React from 'react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoonIcon, SunIcon } from 'lucide-react'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
>
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
For the theme to work, we need to wrap the layout.tsx in the theme provider, but let’s also add the toaster component for a shadcn toast message (notification) as well while we are there.
//src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Toaster as SonnerToaster } from '@/components/ui/sonner'
import { ThemeProvider } from "@/providers/theme-provider";
const font = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: 'StarterSolo',
description: 'Building Growth',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html
lang="en"
suppressHydrationWarning
>
<body className={font.className}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<SonnerToaster position="bottom-left" />
</ThemeProvider>
</body>
</html>
)
}
Let’s create a few links for our Navbar in src/components/global/navbar-links.tsx. This is just a list that check if we are on the current path name, and if so, we add text-primary color to it.
"use client"
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import React from 'react'
const NavLinks = () => {
return (
<nav className="hidden md:block absolute left-[50%] top-[50%] transform translate-x-[-50%] translate-y-[-50%]">
<ul className="flex items-center gap-4 list-none text-black dark:text-white">
<li>
<Link href="/about" className={`hover:text-primary hover:underline underline-offset-4 ${
usePathname() === "/about" ? "text-primary" : ""
} duration-300 ease-linear`}>About</Link>
</li>
<li>
<Link href="/posts" className={`hover:text-primary hover:underline underline-offset-4${
usePathname() === "/posts" ? "text-primary" : ""
} duration-300 ease-linear`}>AI Posts</Link>
</li>
<li>
<Link
href="/portfolio"
className={`hover:text-primary hover:underline underline-offset-4 ${
usePathname() === "/portfolio" ? "text-primary" : ""
} duration-300 ease-linear`}
>
Portfolio
</Link>
</li>
</ul>
</nav>
)
}
export default NavLinks
Let’s add the Navbar now to src/components/global/navbar.tsx so that we can try out our new toggle. The bottom of this navbar adds a sheet that will reveal a trigger menu button if frame is smaller than md (md:hidden). Ill let you add the links like before to this sheet.
import { ModeToggle } from '@/components/global/mode-toggle'
import Image from 'next/image'
import Link from 'next/link'
import React from 'react'
import NavLinks from './navbar-links'
import { Button } from '../ui/button'
import { HeartPulseIcon, MenuIcon, ThermometerIcon } from 'lucide-react'
import { Sheet, SheetContent, SheetTrigger } from '../ui/sheet'
const Navigation = () => { //{ user }: Props
return (
<div className="fixed top-0 right-0 left-0 p-4 flex items-center justify-between z-30 dark:bg-black/40 bg-white/30 backdrop-blur-lg">
<aside className="flex items-center gap-2 group cursor-pointer">
<Image
src={'/logo.png'}
width={40}
height={40}
alt=" logo"
className='rounded-xl'
/>
<span className="text-xl font-bold group-hover:text-primary">StarterSolo.com</span>
</aside>
<div className='hidden md:flex'>
</div>
<NavLinks/>
<aside className="flex gap-2 items-center">
<ModeToggle />
<Sheet>
<SheetTrigger asChild>
<Button className="md:hidden" size="icon" variant="outline">
<MenuIcon className="h-6 w-6" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="right">
<div className="grid gap-6 p-6">
<Link
className="flex items-center gap-3 text-sm font-medium hover:text-primary transition-colors"
href="#"
>
<HeartPulseIcon className="h-5 w-5" />
I create blogs
</Link>
<Link
className="flex items-center gap-3 text-sm font-medium hover:text-primary transition-colors"
href="#"
>
<ThermometerIcon className="h-5 w-5" />
Blogs blogs blogs
</Link>
</div>
</SheetContent>
</Sheet>
</aside>
</div>
)
}
export default Navigation
Then, let’s create another layout.tsx in src/app/(home)/layout.tsx and add our navigation bar above our children where we will place our hero section.
//src/app/(home)/layout.tsx
import Navigation from '@/components/global/navbar'
import React from 'react'
const layout = ({ children }: { children: React.ReactNode }) => {
return (
<main className="h-full ">
<Navigation />
{children}
</main>
)
}
export default layout
Here is the hero component, a simple design to be expanded on located in src/components/global/hero-section.tsx
//src/components/global/hero-section.tsx
import Link from "next/link"
import { ArrowRight } from "lucide-react"
export const Hero = () => {
return (
<div className="py-20 md:py-64">
<div className="text-center px-8">
<h1 className="pb-4 font-extrabold tracking-tight text-transparent text-7xl lg:text-8xl bg-clip-text bg-gradient-to-r from-cyan-400 via-primary to-cyan-400">
StarterSolo
</h1>
<p className="mb-8 text-lg text-zinc-300/40 font-medium bg-clip-text bg-gradient-to-r from-zinc-700 via-primary to-zinc-400">Transform your website with our starter kits at <span className="underline">StarterSolo.com</span></p>
<div className="flex flex-col items-center max-w-xs mx-auto gap-4 sm:max-w-none sm:justify-center sm:flex-row sm:inline-flex"
>
<Link
className="w-full justify-center flex items-center whitespace-nowrap transition duration-150 ease-in-out font-medium rounded px-4 py-1.5 text-zinc-900 bg-gradient-to-r from-white/80 via-white to-white/80 hover:bg-white group hover:underline hover:underline-offset-2"
href="/portfolio">
Go to Blog{" "}
<ArrowRight className="w-3 h-3 tracking-normal text-primary-500 group-hover:translate-x-0.5 transition-transform duration-150 ease-in-out ml-1" />
</Link>
</div>
</div>
</div>
)
}
Finally, let’s important the hero component, here is our home page at src/app/(home)/page.tsx
//src/app/(home)/page.tsx
import { Hero } from '@/components/global/hero-section'
import Image from 'next/image'
import Link from 'next/link'
export default function Home() {
return (
<main>
<div className='mx-auto max-w-7xl text-center h-screen w-full'>
<Hero/>
</div>
</main>
)
}
Make sure to add a logo.png to your public directory and we can now run our website.
npm run dev
If everything is set up properly, you should see a beautiful hero section, a navbar that is responsive, a theme switcher from light to dark, and a working link to your portfolio blogs.
Markdown(md) vs MDX
The script we run earlier for Notion to markdown gives us a .md file, I want the ability to insert react components so my process involves changing the .md files in src/content/portfolio to .mdx
Common things to look out for:
-
indentation throws off the parsing of code blocks
-
Images that come from Notion are hosted on AWS for a short period before the link dies. Therefore, I upload my files to a bucket (ex. uploadthing) then copy and replace the links to images in the newly converted mdx file.
The new contentlayer.config.js file should look like this with it searching for mdx files and specifying the content type to be mdx:
import { defineDocumentType, defineNestedType, makeSource } from 'contentlayer/source-files'
const Image = defineNestedType(() => ({
name: 'Image',
fields: {
width: { type: 'number', required: true },
height: { type: 'number', required: true },
src: { type: 'string', required: true },
},
}))
export const Portfolio = defineDocumentType(() => ({
name: 'Portfolio',
filePathPattern: `portfolio/**/*.mdx`,
contentType: "mdx",
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
slug: { type: 'string', required: true },
notionId: { type: 'string', required: false },
tags: {type: 'list', of: {type: 'string'}},
image: {type: 'nested', of: Image, required: true },
localisation: { type: 'string', required: false},
enabled: { type: 'boolean', required: false}
}
}))
const computedFields = {
slug: {
type: "string",
resolve: (doc) => `/${doc._raw.flattenedPath}`,
},
slugAsParams: {
type: "string",
resolve: (doc) => doc._raw.flattenedPath.split("/").slice(1).join("/"),
},
}
export default makeSource({
contentDirPath: 'src/content',
documentTypes: [ Portfolio]
});
Let’s stylize the Markdown. There are many different ways to go about this but I descided to go with remark and rehype. You will need the following packages in your packages.json installed:
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.13.2",
"rehype-prism-plus": "^2.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^3.0.1",
"@shikijs/rehype": "^1.6.5",
npm i remark-gfm@3.0.1
Notice, remark-gfm is 3.0.1, the newest version seems to be giving errors with contentlayer so i downgraded to 3.0.1 and it seemed to fix it.
Let’s add these plugins to our contentlayer.config.js, it should look like this:
import { defineDocumentType, defineNestedType, makeSource } from 'contentlayer/source-files'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrettyCode from 'rehype-pretty-code'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
/** @type {import('contentlayer/source-files').ComputedFields} */
const Image = defineNestedType(() => ({
name: 'Image',
fields: {
width: { type: 'number', required: true },
height: { type: 'number', required: true },
src: { type: 'string', required: true },
},
}))
export const Portfolio = defineDocumentType(() => ({
name: 'Portfolio',
filePathPattern: `portfolio/**/*.md`,
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
slug: { type: 'string', required: true },
notionId: { type: 'string', required: false },
tags: {type: 'list', of: {type: 'string'}},
image: {type: 'nested', of: Image, required: true },
localisation: { type: 'string', required: false},
enabled: { type: 'boolean', required: false}
}
}))
const computedFields = {
slug: {
type: "string",
resolve: (doc) => `/${doc._raw.flattenedPath}`,
},
slugAsParams: {
type: "string",
resolve: (doc) => doc._raw.flattenedPath.split("/").slice(1).join("/"),
},
}
export default makeSource({
contentDirPath: 'src/content',
documentTypes: [ Portfolio],
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[
rehypePrettyCode,
{
// Use one of Shiki's packaged themes like 'github-dark'
theme: "vitesse-dark",
// Set to true to keep the background color
keepBackground: true ,
onVisitLine(node) {
if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }];
}
},
onVisitHighlightedLine(node) {
node.properties.className.push("highlighted");
},
onVisitHighlightedWord(node, id) {
node.properties.className = ["word"];
},
}
],
[
rehypeAutolinkHeadings,
{
properties: {
className: ['subheading-anchor'],
ariaLabel: 'Link to section',
}
}
],
]
}
});
The look of the code block can be changed by using a different shiki theme by changing vitesse-dark to your choosing.
Define the component html tags for the parser that we will use to style our mdx, it uses a mdx wrapper component, so make a new component in src/components/blog/mdx-component.tsx
import * as React from "react"
import Image from "next/image"
import { useMDXComponent } from "next-contentlayer/hooks"
import { cn } from "@/lib/utils"
const components = {
h1: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1
className={cn(
"mt-2 scroll-m-20 text-4xl font-bold tracking-tight",
className
)}
{...props}
/>
),
h2: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2
className={cn(
"mt-10 scroll-m-20 border-b pb-1 text-3xl font-semibold tracking-tight first:mt-0",
className
)}
{...props}
/>
),
h3: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3
className={cn(
"mt-8 scroll-m-20 text-2xl font-semibold tracking-tight",
className
)}
{...props}
/>
),
h4: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4
className={cn(
"mt-8 scroll-m-20 text-xl font-semibold tracking-tight",
className
)}
{...props}
/>
),
h5: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h5
className={cn(
"mt-8 scroll-m-20 text-lg font-semibold tracking-tight",
className
)}
{...props}
/>
),
h6: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h6
className={cn(
"mt-8 scroll-m-20 text-base font-semibold tracking-tight",
className
)}
{...props}
/>
),
a: ({ className, ...props }: React.HTMLAttributes<HTMLAnchorElement>) => (
<a
className={cn("font-medium underline underline-offset-4", className)}
{...props}
/>
),
p: ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p
className={cn("leading-7 [&:not(:first-child)]:mt-6", className)}
{...props}
/>
),
ul: ({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) => (
<ul className={cn("my-6 ml-6 list-disc", className)} {...props} />
),
ol: ({ className, ...props }: React.HTMLAttributes<HTMLOListElement>) => (
<ol className={cn("my-6 ml-6 list-decimal", className)} {...props} />
),
li: ({ className, ...props }: React.HTMLAttributes<HTMLLIElement>) => (
<li className={cn("mt-2", className)} {...props} />
),
blockquote: ({ className, ...props }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote
className={cn(
"mt-6 border-l-2 pl-6 italic [&>*]:text-muted-foreground",
className
)}
{...props}
/>
),
img: ({
className,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement>) => (
// eslint-disable-next-line @next/next/no-img-element
<img className={cn("rounded-md border", className)} alt={alt} {...props} />
),
hr: ({ ...props }) => <hr className="my-4 md:my-8" {...props} />,
table: ({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
<div className="my-6 w-full overflow-y-auto">
<table className={cn("w-full", className)} {...props} />
</div>
),
tr: ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr
className={cn("m-0 border-t p-0 even:bg-muted", className)}
{...props}
/>
),
th: ({ className, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
<th
className={cn(
"border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right",
className
)}
{...props}
/>
),
td: ({ className, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
<td
className={cn(
"border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right",
className
)}
{...props}
/>
),
pre: ({ className, ...props }: React.HTMLAttributes<HTMLPreElement>) => (
<pre
className={cn(
"mb-4 mt-6 overflow-x-auto rounded-lg border bg-black py-4",
className
)}
{...props}
/>
),
code: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
<code
className={cn(
"relative rounded border px-[0.3rem] py-[0.2rem] font-mono text-sm",
className
)}
{...props}
/>
),
Image,
}
interface MdxProps {
code: string
}
export function Mdx({ code }: MdxProps) {
const Component = useMDXComponent(code)
return (
<div className="mdx dark:bg-slate-900">
<Component components={components} />
</div>
)
}
Finally, let’s call this component using the body and component type we made above for code:
<Mdx code={doc.body.code}/>
And stick it in our Human made blog posts page, let’s change our src/app/(blog)/portfolio/[slug]/page.tsx
import { Mdx } from '@/components/blog/mdx-wrapper'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Redis } from '@upstash/redis'
import { allPortfolios } from 'contentlayer/generated'
import { ChevronLeft, EyeIcon } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import React, { FC } from 'react'
import { Metadata } from 'next';
interface PostProps {
params: {
slug: string
}
}
async function getDocFromParams(slug: string){
const doc = allPortfolios.find((doc) => doc.slug === slug)
if(!doc) notFound()
return doc
}
const Post = async ({params}: PostProps) => {
const doc = await getDocFromParams(params.slug)
return (
<div>
<div className=" relative w-full min-h-[calc(100vh-600px)] border-b-[0.1px] border-accent mt-[-20px] py-10 z-20 ml-0">
<div className='mb-10'/>
<div className="flex flex-col justify-end items-end px-10">
<h1 className="flex mt-2 text-2xl sm:text-3xl max-w-5xl font-heading text-right md:text-3xl lg:text-5xl leading-tight ml-auto dark:text-white text-slate-900">
{doc.title}
</h1>
</div>
<div className="flex justify-center mt-3 items-center">
<Link
href="/portfolio"
className={cn(
buttonVariants({ variant: "ghost" }),
"inline-flex hover:bg-transparent"
)}
>
<ChevronLeft className="mr-2 w-4 h-4" />
See all posts
</Link>
</div>
</div>
<Image src={doc?.image.src} width={doc?.image.width} height={doc?.image.height} alt={doc?.title} className='rounded-t-xl'/>
<Mdx code={doc.body.code}/>
</div>
)
}
export default Post
Now if you run our localhost, we should see a stylized post.
npm run dev
Now, the process for human made blog posts is:
- Save Notion document in the database, selecting each required field.
- $node ./scripts/notion.js
- Convert .md files made by Kotaps’s parsing script to .mdx
- Add images to bucket and copy image links to mdx file.
- $npm run dev
Wrapping up, we were able to apply Shadcn components, add dark mode/light mode, add a mobile responsive sheet, and stylized our markdown using remark & rehype so as to allow us to make Human blog posts. Let’s get AI to make some posts now…that lazy bugger.