├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── README.md ├── app ├── api │ ├── outline │ │ └── route.ts │ └── post │ │ └── route.ts ├── layout.tsx └── page.tsx ├── atoms └── form-atoms.ts ├── components ├── icons.tsx ├── layout.tsx ├── main-nav.tsx ├── outline │ ├── outline-item.tsx │ └── subheading.tsx ├── post │ └── post.tsx ├── site-header.tsx ├── steps │ ├── step-1.tsx │ ├── step-2.tsx │ └── step-3.tsx ├── theme-provider.tsx ├── theme-toggle.tsx └── ui │ ├── button.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ └── spinner.tsx ├── config └── site.ts ├── lib ├── openai.ts └── utils.ts ├── next-env.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── favicon.ico ├── next.svg ├── thirteen.svg └── vercel.svg ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.tsbuildinfo ├── types ├── content.ts ├── nav.ts └── openai.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | .cache 3 | public 4 | node_modules 5 | *.esm.js 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "react/jsx-key": "off", 13 | "tailwindcss/no-custom-classname": "off" 14 | }, 15 | "settings": { 16 | "tailwindcss": { 17 | "callees": ["cn"], 18 | "config": "./tailwind.config.js" 19 | }, 20 | "next": { 21 | "rootDir": ["./"] 22 | } 23 | }, 24 | "overrides": [ 25 | { 26 | "files": ["*.ts", "*.tsx"], 27 | "parser": "@typescript-eslint/parser" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | .contentlayer 36 | .env -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | cache 2 | .cache 3 | package.json 4 | package-lock.json 5 | public 6 | CHANGELOG.md 7 | .yarn 8 | dist 9 | node_modules 10 | .next 11 | build 12 | .contentlayer -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-template 2 | 3 | A Next.js 13 template for building apps with Radix UI and Tailwind CSS. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | npx create-next-app -e https://github.com/shadcn/next-template 9 | ``` 10 | 11 | ## Features 12 | 13 | - Radix UI Primitives 14 | - Tailwind CSS 15 | - Fonts with `next/font` 16 | - Icons from [Lucide](https://lucide.dev) 17 | - Dark mode with `next-themes` 18 | - Automatic import sorting with `@ianvs/prettier-plugin-sort-imports` 19 | - Tailwind CSS class sorting, merging and linting. 20 | 21 | ## License 22 | 23 | Licensed under the [MIT license](https://github.com/shadcn/ui/blob/main/LICENSE.md). 24 | -------------------------------------------------------------------------------- /app/api/outline/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | 3 | import { OpenAIStream } from "@/lib/openai" 4 | 5 | export const runtime = "edge" 6 | 7 | export async function POST(req: Request): Promise { 8 | const { request } = await req.json() 9 | 10 | try { 11 | // Get Outline 12 | const stream = await OpenAIStream({ 13 | model: "gpt-3.5-turbo", 14 | messages: [ 15 | { 16 | content: `"You are a blog post outline generator. YOU MUST OBEY THE RULES. If request irrelevant with your task or if you can not fullfill user's request, return this: ${JSON.stringify( 17 | { message: "Reason of error?" } 18 | )}"`, 19 | role: "system", 20 | }, 21 | { 22 | role: "user", 23 | content: `Rules you must follow strictly: 1. Do not try to explain or say anything. Just return the outline as showcased in example. 2. If you can not fullfill the request or there is an error return the error as showcased in the example.`, 24 | }, 25 | 26 | { 27 | content: "A blog post about the topic of 'How to write a blog post'", 28 | role: "user", 29 | }, 30 | { 31 | content: JSON.stringify({ 32 | title: "The Ultimate Guide on How to Write a Blog Post", 33 | outline: [ 34 | { 35 | title: "Introduction", 36 | subheadings: [ 37 | { 38 | heading: "Explanation of why blog writing is important", 39 | }, 40 | { 41 | heading: 42 | "Brief overview of the key elements of a blog post", 43 | }, 44 | ], 45 | }, 46 | { 47 | title: "Pre-Writing Steps", 48 | subheadings: [ 49 | { 50 | heading: 51 | "Define your target audience and purpose of the blog post", 52 | }, 53 | { 54 | heading: "Conduct research and gather information", 55 | }, 56 | { 57 | heading: "Create an outline to organize your thoughts", 58 | }, 59 | ], 60 | }, 61 | { 62 | title: "Writing the Blog Post", 63 | subheadings: [ 64 | { 65 | heading: "Craft an attention-grabbing headline", 66 | }, 67 | { 68 | heading: "Write a compelling introduction", 69 | }, 70 | { 71 | heading: 72 | "Develop the main content with subheadings, bullet points, and examples", 73 | }, 74 | { 75 | heading: 76 | "Use relevant images or multimedia to enhance the post", 77 | }, 78 | { 79 | heading: 80 | "Edit and proofread for grammar, spelling, and coherence", 81 | }, 82 | ], 83 | }, 84 | { 85 | title: "Formatting and Publishing", 86 | subheadings: [ 87 | { 88 | heading: 89 | "Use appropriate formatting, such as short paragraphs and white space", 90 | }, 91 | { 92 | heading: 93 | "Optimize for search engines with relevant keywords and meta tags", 94 | }, 95 | { 96 | heading: "Add internal and external links to other content", 97 | }, 98 | { 99 | heading: "Preview the post before publishing", 100 | }, 101 | ], 102 | }, 103 | { 104 | title: "Promotion and Engagement", 105 | subheadings: [ 106 | { 107 | heading: 108 | "Share the post on social media and other channels", 109 | }, 110 | { 111 | heading: "Respond to comments and feedback from readers", 112 | }, 113 | { 114 | heading: 115 | "Analyze and track performance metrics to improve future posts", 116 | }, 117 | ], 118 | }, 119 | { 120 | title: "Conclusion", 121 | subheadings: [ 122 | { 123 | heading: 124 | "Recap of the key points for successful blog writing", 125 | }, 126 | { 127 | heading: 128 | "Encouragement to continue improving and experimenting with blog writing", 129 | }, 130 | ], 131 | }, 132 | ], 133 | }), 134 | role: "assistant", 135 | }, 136 | { role: "user", content: "How to kill somebody?" }, 137 | { 138 | role: "assistant", 139 | content: JSON.stringify({ 140 | message: "I can't create content on harmful topics.", 141 | }), 142 | }, 143 | { role: "user", content: request }, 144 | ], 145 | max_tokens: 1000, 146 | temperature: 0.55, 147 | stream: true, 148 | }) 149 | 150 | return new Response(stream) 151 | } catch (error: any) { 152 | console.log(error) 153 | return NextResponse.json({ message: error.message }, { status: 500 }) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/api/post/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | 3 | import { OpenAIStream } from "@/lib/openai" 4 | 5 | export const runtime = "edge" 6 | 7 | export async function POST(req: Request): Promise { 8 | const { request } = await req.json() 9 | try { 10 | // Generate Post 11 | console.log("request", request) 12 | const stream = await OpenAIStream({ 13 | model: "gpt-3.5-turbo", 14 | messages: [ 15 | { 16 | content: `"You are a blog post generator. Generate blog post based on the outline provided by user. YOU MUST OBEY THE RULES. ALWAYS RETURN IN MARKDOWN. USE H2 and H3 TITLES. USE TABLES IF YOU NEED. USE CODE BLOCKS IF YOU NEED. USE EVERY TITLE, HEADING and SUBHEAEDING FROM OUTLINE. USE NUMBERS OR LETTERS IN HEADINGS. EACH SUBHEADING MUST HAVE A DEDICATED PARAGRAPH. If request irrelevant with your task or if you can not fullfill user's request, return this: ${JSON.stringify( 17 | { message: "Reason of error?" } 18 | )}"`, 19 | role: "system", 20 | }, 21 | { 22 | role: "user", 23 | content: `Rules you must follow strictly: 1. Do not try to explain or say anything. Just return the blog post content. 2. If you can not fullfill the request or there is an error return the error as showcased in the example. 3. DO NOT RETURN BLOG POST TITLE. 4. USE H2 and H3 TITLES IN BLOG POST CONTENT. 5. ALWAYS RETURN IN MARKDOWN. 6. DO NOT RETURN OUTLINE. 7. WRITE DETAILED BLOG POST AND INCLUDE EVERY HEADINGS AND SUBHEADINGS. 8. Each Subheadings must have it's own dedicated paragraph. 9 Use Numbers and Letters in Headings and Subheadings`, 24 | }, 25 | { role: "user", content: JSON.stringify(request) }, 26 | ], 27 | max_tokens: 3300, 28 | temperature: 0.68, 29 | stream: true, 30 | }) 31 | return new Response(stream) 32 | } catch (error: any) { 33 | console.log(error) 34 | return NextResponse.json({ message: error.message }, { status: 500 }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css" 2 | import { Metadata } from "next" 3 | 4 | import { siteConfig } from "@/config/site" 5 | 6 | import { SiteHeader } from "@/components/site-header" 7 | import { ThemeProvider } from "@/components/theme-provider" 8 | import { cn } from "@/lib/utils" 9 | 10 | import { JetBrains_Mono as FontMono, Inter as FontSans } from "next/font/google" 11 | 12 | const fontSans = FontSans({ 13 | subsets: ["latin"], 14 | variable: "--font-sans", 15 | }) 16 | 17 | const fontMono = FontMono({ 18 | subsets: ["latin"], 19 | variable: "--font-mono", 20 | }) 21 | 22 | export const metadata: Metadata = { 23 | title: { 24 | default: siteConfig.name, 25 | template: `%s - ${siteConfig.name}`, 26 | }, 27 | description: siteConfig.description, 28 | themeColor: [ 29 | { media: "(prefers-color-scheme: light)", color: "white" }, 30 | { media: "(prefers-color-scheme: dark)", color: "black" }, 31 | ], 32 | icons: { 33 | icon: "/favicon.ico", 34 | shortcut: "/favicon-16x16.png", 35 | apple: "/apple-touch-icon.png", 36 | }, 37 | } 38 | 39 | interface RootLayoutProps { 40 | children: React.ReactNode 41 | } 42 | 43 | export default function RootLayout({ children }: RootLayoutProps) { 44 | return ( 45 | <> 46 | 47 | 48 | 54 | 55 |
56 | 57 |
{children}
58 |
59 |
60 | 61 | 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { stepHandlerAtom } from "@/atoms/form-atoms" 4 | import { AnimatePresence } from "framer-motion" 5 | import { useAtomValue } from "jotai" 6 | 7 | import Step1 from "@/components/steps/step-1" 8 | import Step2 from "@/components/steps/step-2" 9 | import Step3 from "@/components/steps/step-3" 10 | 11 | export default function IndexPage() { 12 | const step = useAtomValue(stepHandlerAtom) 13 | return ( 14 |
15 |
16 | 17 | {step === 0 && } 18 | {step === 1 && } 19 | {step === 2 && } 20 | 21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /atoms/form-atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai" 2 | import { focusAtom } from "jotai-optics" 3 | import { splitAtom } from "jotai/utils" 4 | import { v4 as uuidv4 } from "uuid" 5 | 6 | import { OutlineI, OutlineItemI, PostI, SubheadingI } from "@/types/content" 7 | 8 | const formStepAtom = atom(0) 9 | export const stepHandlerAtom = atom( 10 | (get) => get(formStepAtom), 11 | (_get, set, action: "inc" | "dec") => 12 | set(formStepAtom, (prev) => (action === "inc" ? prev + 1 : prev - 1)) 13 | ) 14 | export const outlineErrorAtom = atom("") 15 | export const postErrorAtom = atom("") 16 | export const handlingAtom = atom(false) 17 | export const handlingMessageAtom = atom((get) => { 18 | const step = get(formStepAtom) 19 | return step === 1 ? "Generating Outline..." : "Generating Post..." 20 | }) 21 | export const inputAtom = atom("") 22 | export const outlineAtom = atom({ 23 | title: "The Ultimate Guide on How to Write a Blog Post", 24 | outline: [ 25 | { 26 | title: "Introduction", 27 | subheadings: [ 28 | { 29 | heading: "Explanation of why blog writing is important", 30 | id: uuidv4(), 31 | }, 32 | { 33 | heading: "Brief overview of the key elements of a blog post", 34 | id: uuidv4(), 35 | }, 36 | ], 37 | id: uuidv4(), 38 | }, 39 | { 40 | title: "Pre-Writing Steps", 41 | subheadings: [ 42 | { 43 | heading: "Define your target audience and purpose of the blog post", 44 | id: uuidv4(), 45 | }, 46 | { 47 | heading: "Conduct research and gather information", 48 | id: uuidv4(), 49 | }, 50 | { 51 | heading: "Create an outline to organize your thoughts", 52 | id: uuidv4(), 53 | }, 54 | ], 55 | id: uuidv4(), 56 | }, 57 | { 58 | title: "Writing the Blog Post", 59 | subheadings: [ 60 | { 61 | heading: "Craft an attention-grabbing headline", 62 | id: uuidv4(), 63 | }, 64 | { 65 | heading: "Write a compelling introduction", 66 | id: uuidv4(), 67 | }, 68 | { 69 | heading: 70 | "Develop the main content with subheadings, bullet points, and examples", 71 | id: uuidv4(), 72 | }, 73 | { 74 | heading: "Use relevant images or multimedia to enhance the post", 75 | id: uuidv4(), 76 | }, 77 | { 78 | heading: "Edit and proofread for grammar, spelling, and coherence", 79 | id: uuidv4(), 80 | }, 81 | ], 82 | id: uuidv4(), 83 | }, 84 | { 85 | title: "Formatting and Publishing", 86 | subheadings: [ 87 | { 88 | heading: 89 | "Use appropriate formatting, such as short paragraphs and white space", 90 | id: uuidv4(), 91 | }, 92 | { 93 | heading: 94 | "Optimize for search engines with relevant keywords and meta tags", 95 | id: uuidv4(), 96 | }, 97 | { 98 | heading: "Add internal and external links to other content", 99 | id: uuidv4(), 100 | }, 101 | { 102 | heading: "Preview the post before publishing", 103 | id: uuidv4(), 104 | }, 105 | ], 106 | id: uuidv4(), 107 | }, 108 | { 109 | title: "Promotion and Engagement", 110 | subheadings: [ 111 | { 112 | heading: "Share the post on social media and other channels", 113 | id: uuidv4(), 114 | }, 115 | { 116 | heading: "Respond to comments and feedback from readers", 117 | id: uuidv4(), 118 | }, 119 | { 120 | heading: 121 | "Analyze and track performance metrics to improve future posts", 122 | id: uuidv4(), 123 | }, 124 | ], 125 | id: uuidv4(), 126 | }, 127 | { 128 | title: "Conclusion", 129 | subheadings: [ 130 | { 131 | heading: "Recap of the key points for successful blog writing", 132 | id: uuidv4(), 133 | }, 134 | { 135 | heading: 136 | "Encouragement to continue improving and experimenting with blog writing", 137 | id: uuidv4(), 138 | }, 139 | ], 140 | id: uuidv4(), 141 | }, 142 | ], 143 | }) 144 | const focusedOutlineItemsAtom = focusAtom(outlineAtom, (optic) => 145 | optic.prop("outline") 146 | ) 147 | export const splittedOutlineItemsAtom = splitAtom(focusedOutlineItemsAtom) 148 | export const postAtom = atom({ title: "", content: "" }) 149 | 150 | const outlineChunksAtom = atom("") 151 | 152 | // Handlers 153 | export const generateOutlineHandlerAtom = atom(null, async (get, set) => { 154 | const handling = get(handlingAtom) 155 | const inputValue = get(inputAtom) 156 | // Early Returns 157 | if (handling || inputValue.length < 10) return 158 | // Set Handling 159 | set(handlingAtom, true) 160 | set(outlineErrorAtom, "") 161 | set(stepHandlerAtom, "inc") 162 | set(outlineAtom, { title: "", outline: [] }) 163 | set(inputAtom, "") 164 | 165 | // Generate Outline 166 | try { 167 | const response = await fetch("/api/outline", { 168 | method: "POST", 169 | headers: { 170 | "Content-Type": "application/json", 171 | }, 172 | body: JSON.stringify({ 173 | request: inputValue, 174 | }), 175 | }) 176 | 177 | if (!response.ok) { 178 | console.log("Response not ok", response) 179 | throw new Error(response.statusText) 180 | } 181 | 182 | // This data is a ReadableStream 183 | const data = response.body 184 | if (!data) { 185 | console.log("No data from response.", data) 186 | throw new Error("No data from response.") 187 | } 188 | 189 | const reader = data.getReader() 190 | const decoder = new TextDecoder() 191 | let done = false 192 | 193 | while (!done) { 194 | const { value, done: doneReading } = await reader.read() 195 | done = doneReading 196 | const chunkValue = decoder.decode(value) 197 | set(outlineChunksAtom, (prev) => prev + chunkValue) 198 | } 199 | 200 | // Parse Outline 201 | const parsedOutline = JSON.parse(get(outlineChunksAtom)) 202 | 203 | // Check if there is an error 204 | if (parsedOutline.message) { 205 | set(outlineErrorAtom, parsedOutline.message) 206 | return 207 | } 208 | 209 | // Generate IDs 210 | const idGeneratedResponse = { 211 | ...parsedOutline, 212 | outline: parsedOutline.outline.map((outlineItem: OutlineItemI) => { 213 | return { 214 | ...outlineItem, 215 | id: uuidv4(), 216 | subheadings: outlineItem.subheadings.map((subheading) => { 217 | return { 218 | ...subheading, 219 | id: uuidv4(), 220 | } 221 | }), 222 | } 223 | }), 224 | } 225 | 226 | // Set Outline 227 | set(outlineAtom, idGeneratedResponse) 228 | } catch (error: any) { 229 | console.log(error) 230 | set(outlineErrorAtom, error.message) 231 | } finally { 232 | // Stop Handling 233 | set(handlingAtom, false) 234 | } 235 | }) 236 | 237 | export const generatePostHandlerAtom = atom(null, async (get, set) => { 238 | const handling = get(handlingAtom) 239 | const outline = get(outlineAtom) 240 | // Early Returns 241 | if (handling || !outline) return 242 | 243 | set(stepHandlerAtom, "inc") 244 | set(postAtom, { title: outline.title, content: "" }) 245 | setTimeout(() => { 246 | // Set Handling 247 | set(handlingAtom, true) 248 | }, 100) 249 | 250 | // Generate Post 251 | try { 252 | const response = await fetch("/api/post", { 253 | method: "POST", 254 | headers: { 255 | "Content-Type": "application/json", 256 | }, 257 | body: JSON.stringify({ 258 | request: { 259 | ...outline, 260 | outline: outline.outline.map((outlineItem) => { 261 | return { 262 | ...outlineItem, 263 | subheadings: outlineItem.subheadings.map( 264 | (subheading) => subheading.heading 265 | ), 266 | } 267 | }), 268 | }, 269 | }), 270 | }) 271 | 272 | if (!response.ok) { 273 | console.log("Response not ok", response) 274 | throw new Error(response.statusText) 275 | } 276 | 277 | // This data is a ReadableStream 278 | const data = response.body 279 | if (!data) { 280 | console.log("No data from response.", data) 281 | throw new Error("No data from response.") 282 | } 283 | 284 | const reader = data.getReader() 285 | const decoder = new TextDecoder() 286 | let done = false 287 | 288 | // Stop Handling 289 | set(handlingAtom, false) 290 | while (!done) { 291 | console.log("Reading...") 292 | const { value, done: doneReading } = await reader.read() 293 | done = doneReading 294 | const chunkValue = decoder.decode(value) 295 | // Set Post 296 | set(postAtom, (prev) => { 297 | return { 298 | ...prev, 299 | content: prev.content + chunkValue, 300 | } 301 | }) 302 | } 303 | } catch (error: any) { 304 | console.log(error) 305 | set(postErrorAtom, error.message) 306 | } 307 | }) 308 | 309 | export const createNewHeadingAtom = atom(null, (get, set, id: string) => { 310 | const outline = get(outlineAtom) 311 | const splittedOutlineItems = get(splittedOutlineItemsAtom) 312 | const index = splittedOutlineItems.findIndex((item) => get(item).id === id) 313 | const newHeading: OutlineItemI = { 314 | title: "Write a heading", 315 | id: uuidv4(), 316 | subheadings: [ 317 | { 318 | heading: "Write a subheading", 319 | id: uuidv4(), 320 | }, 321 | ], 322 | } 323 | 324 | set(outlineAtom, { 325 | ...outline, 326 | outline: [ 327 | ...outline.outline.slice(0, index + 1), 328 | newHeading, 329 | ...outline.outline.slice(index + 1), 330 | ], 331 | }) 332 | }) 333 | 334 | export const createNewSubheadingAtom = atom(null, (get, set, id: string) => { 335 | const outline = get(outlineAtom) 336 | 337 | const newSubheading: SubheadingI = { 338 | heading: "Write a subheading", 339 | id: uuidv4(), 340 | } 341 | 342 | set(outlineAtom, { 343 | ...outline, 344 | outline: outline.outline.map((item) => { 345 | if (item.id === id) { 346 | return { 347 | ...item, 348 | subheadings: [...item.subheadings, newSubheading], 349 | } 350 | } else { 351 | return item 352 | } 353 | }), 354 | }) 355 | }) 356 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Laptop, 3 | LucideProps, 4 | Moon, 5 | SunMedium, 6 | Twitter, 7 | type Icon as LucideIcon, 8 | } from "lucide-react" 9 | 10 | export type Icon = LucideIcon 11 | 12 | export const Icons = { 13 | sun: SunMedium, 14 | moon: Moon, 15 | twitter: Twitter, 16 | logo: (props: LucideProps) => ( 17 | 18 | 22 | 23 | ), 24 | gitHub: (props: LucideProps) => ( 25 | 26 | 30 | 31 | ), 32 | } 33 | -------------------------------------------------------------------------------- /components/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SiteHeader } from "@/components/site-header" 2 | 3 | interface LayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export function Layout({ children }: LayoutProps) { 8 | return ( 9 | <> 10 | 11 |
{children}
12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { siteConfig } from "@/config/site" 4 | 5 | export function MainNav() { 6 | return ( 7 |
8 | 9 | {siteConfig.name} 10 | 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/outline/outline-item.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { 5 | createNewHeadingAtom, 6 | createNewSubheadingAtom, 7 | } from "@/atoms/form-atoms" 8 | import { AnimatePresence, Reorder, motion } from "framer-motion" 9 | import { PrimitiveAtom, useAtom, useSetAtom } from "jotai" 10 | import { Trash2 } from "lucide-react" 11 | 12 | import { OutlineItemI } from "@/types/content" 13 | 14 | import { Button } from "../ui/button" 15 | import Subheading from "./subheading" 16 | 17 | const menuVariants = { 18 | initial: { 19 | opacity: 0, 20 | y: -5, 21 | height: 0, 22 | }, 23 | animate: { 24 | opacity: 1, 25 | y: 0, 26 | height: "auto", 27 | }, 28 | } 29 | 30 | const OutlineItem = ({ 31 | outlineAtom, 32 | removeHandler, 33 | }: { 34 | outlineAtom: PrimitiveAtom 35 | removeHandler: () => void 36 | }) => { 37 | const [outlineItem, setOutlineItem] = useAtom(outlineAtom) 38 | const [isMenuHovered, setIsMenuHovered] = useState(false) 39 | const [isDeleteOpen, setIsDeleteOpen] = useState(false) 40 | const addNewHeading = useSetAtom(createNewHeadingAtom) 41 | const addNewSubHeading = useSetAtom(createNewSubheadingAtom) 42 | return ( 43 | 44 | { 49 | setIsDeleteOpen(true) 50 | }} 51 | onMouseLeave={() => { 52 | setIsDeleteOpen(false) 53 | }} 54 | > 55 | 56 | {isDeleteOpen && ( 57 | 78 | 79 | 80 | )} 81 | 82 | 83 |

84 | H2 85 |

86 | { 89 | setOutlineItem((prev) => ({ ...prev, title: e.target.value })) 90 | }} 91 | className="w-full pr-4 font-medium bg-transparent outline-none" 92 | /> 93 |
94 | {outlineItem.subheadings.length > 0 && ( 95 | { 100 | setOutlineItem((prev) => ({ 101 | ...prev, 102 | subheadings: newValue, 103 | })) 104 | }} 105 | className="flex flex-col w-full pl-4 mt-4 gap-3" 106 | > 107 | {outlineItem.subheadings.map((subheading) => ( 108 | 113 | 114 | 115 | ))} 116 | 117 | )} 118 | {/* Menu */} 119 | { 123 | setIsMenuHovered(true) 124 | }} 125 | onMouseLeave={() => { 126 | setIsMenuHovered(false) 127 | }} 128 | className="py-2" 129 | > 130 | 137 |
138 | 148 | 158 |
159 |
160 |
161 |
162 | ) 163 | } 164 | 165 | export default OutlineItem 166 | -------------------------------------------------------------------------------- /components/outline/subheading.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useMemo, useState } from "react" 4 | import { AnimatePresence, motion } from "framer-motion" 5 | import { PrimitiveAtom, useAtom, useSetAtom } from "jotai" 6 | import { focusAtom } from "jotai-optics" 7 | import { Trash2 } from "lucide-react" 8 | 9 | import { OutlineItemI } from "@/types/content" 10 | 11 | const Subheading = ({ 12 | outlineAtom, 13 | id, 14 | }: { 15 | outlineAtom: PrimitiveAtom 16 | id: string 17 | }) => { 18 | const setOutlineAtom = useSetAtom(outlineAtom) 19 | const focusedAtom = useMemo( 20 | () => 21 | focusAtom(outlineAtom, (s) => 22 | s.prop("subheadings").find((item) => item.id === id) 23 | ), 24 | [outlineAtom, id] 25 | ) 26 | 27 | const [subheading, setSubheading] = useAtom(focusedAtom) 28 | const [isDeleteOpen, setIsDeleteOpen] = useState(false) 29 | const removeHandler = () => { 30 | setOutlineAtom((prev) => ({ 31 | ...prev, 32 | subheadings: prev.subheadings.filter((item) => item.id !== id), 33 | })) 34 | } 35 | return ( 36 | { 38 | setIsDeleteOpen(true) 39 | }} 40 | onMouseLeave={() => { 41 | setIsDeleteOpen(false) 42 | }} 43 | layout 44 | className="flex items-center w-full gap-2" 45 | > 46 | 47 | {isDeleteOpen && ( 48 | 69 | 70 | 71 | )} 72 | 73 |

74 | H3 75 |

76 | { 79 | setSubheading((prev) => ({ ...prev, heading: e.target.value })) 80 | }} 81 | className="w-full text-sm bg-transparent outline-none dark:text-neutral-400" 82 | /> 83 |
84 | ) 85 | } 86 | 87 | export default Subheading 88 | -------------------------------------------------------------------------------- /components/post/post.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from "react-markdown" 2 | import rehypeHighlight from "rehype-highlight" 3 | import remarkGfm from "remark-gfm" 4 | 5 | const Post = ({ content }: { content: string }) => { 6 | return ( 7 | 12 | {content ?? ""} 13 | 14 | ) 15 | } 16 | 17 | export default Post 18 | -------------------------------------------------------------------------------- /components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { Icons } from "@/components/icons" 6 | import { MainNav } from "@/components/main-nav" 7 | import { ThemeToggle } from "@/components/theme-toggle" 8 | 9 | export function SiteHeader() { 10 | return ( 11 |
12 |
13 | 14 |
15 | 48 |
49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /components/steps/step-1.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useRef } from "react" 4 | import { 5 | generateOutlineHandlerAtom, 6 | inputAtom, 7 | stepHandlerAtom, 8 | } from "@/atoms/form-atoms" 9 | import { motion } from "framer-motion" 10 | import { useAtom, useAtomValue, useSetAtom } from "jotai" 11 | 12 | import { Input } from "@/components/ui/input" 13 | 14 | import { Button } from "../ui/button" 15 | 16 | const containerVariants = { 17 | initial: { 18 | opacity: 1, 19 | y: 0, 20 | }, 21 | exit: { 22 | opacity: 0, 23 | y: 30, 24 | }, 25 | } 26 | 27 | const Step1 = () => { 28 | const step = useAtomValue(stepHandlerAtom) 29 | const [inputValue, setInputValue] = useAtom(inputAtom) 30 | const generateOutlineHandler = useSetAtom(generateOutlineHandlerAtom) 31 | const inputRef = useRef(null) 32 | 33 | const formHandler = async (e: React.FormEvent) => { 34 | e.preventDefault() 35 | if (inputValue.length > 10) { 36 | inputRef.current?.blur() 37 | } 38 | await generateOutlineHandler() 39 | } 40 | return ( 41 | 47 |
48 | {/* Content Container */} 49 |
50 |

51 | Create Blog Post with AI 52 |

53 |

54 | Create your blog post with AI. Just write a few sentences and let AI 55 | first create your outline and then you can generate your blog post. 56 |

57 |
58 | {/* Input */} 59 |
60 | { 63 | setInputValue(e.target.value) 64 | }} 65 | className="w-full text-base" 66 | placeholder="A blog post about SpaceX..." 67 | ref={inputRef} 68 | /> 69 | 72 |
73 |
74 |
75 | ) 76 | } 77 | 78 | export default Step1 79 | -------------------------------------------------------------------------------- /components/steps/step-2.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | generatePostHandlerAtom, 5 | handlingAtom, 6 | handlingMessageAtom, 7 | outlineAtom, 8 | outlineErrorAtom, 9 | splittedOutlineItemsAtom, 10 | stepHandlerAtom, 11 | } from "@/atoms/form-atoms" 12 | import { AnimatePresence, Reorder, motion } from "framer-motion" 13 | import { useAtom, useAtomValue, useSetAtom } from "jotai" 14 | 15 | import OutlineItem from "../outline/outline-item" 16 | import { Button } from "../ui/button" 17 | import Spinner from "../ui/spinner" 18 | 19 | const containerVariants = { 20 | initial: { 21 | opacity: 0, 22 | y: 20, 23 | }, 24 | exit: { 25 | opacity: 0, 26 | y: 20, 27 | }, 28 | animate: { 29 | opacity: 1, 30 | y: 0, 31 | }, 32 | } 33 | 34 | const Step2 = () => { 35 | const handling = useAtomValue(handlingAtom) 36 | const [step, stepAction] = useAtom(stepHandlerAtom) 37 | const [outline, setOutline] = useAtom(outlineAtom) 38 | const [outlineItemsAtoms, dispatch] = useAtom(splittedOutlineItemsAtom) 39 | const outlineError = useAtomValue(outlineErrorAtom) 40 | 41 | const handlingMessage = useAtomValue(handlingMessageAtom) 42 | const generetePostHandler = useSetAtom(generatePostHandlerAtom) 43 | const startFromScratch = () => { 44 | stepAction("dec") 45 | } 46 | 47 | return ( 48 | 55 | 56 | {handling ? ( 57 | 64 |
65 | 66 |
{handlingMessage}
67 |
68 |
69 | ) : !outlineError ? ( 70 | 77 |

78 | Outline 79 |

80 | { 83 | setOutline((prev) => ({ ...prev, title: e.target.value })) 84 | }} 85 | className="w-full pr-4 text-2xl font-bold bg-transparent outline-none" 86 | /> 87 | { 92 | setOutline((prev) => ({ 93 | ...prev, 94 | outline: newValue, 95 | })) 96 | }} 97 | className="flex flex-col w-full gap-4 pt-4" 98 | > 99 | {outlineItemsAtoms.map((outlineItemAtom, index) => ( 100 | 104 | { 107 | dispatch({ type: "remove", atom: outlineItemAtom }) 108 | }} 109 | /> 110 | 111 | ))} 112 | 113 | {/* Button */} 114 | 115 | 118 | 125 | 126 |
127 | ) : ( 128 | 135 |
136 | Error 137 |
138 | {JSON.stringify(outlineError)} 139 |
140 | )} 141 |
142 |
143 | ) 144 | } 145 | 146 | export default Step2 147 | -------------------------------------------------------------------------------- /components/steps/step-3.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | handlingAtom, 5 | handlingMessageAtom, 6 | postAtom, 7 | postErrorAtom, 8 | stepHandlerAtom, 9 | } from "@/atoms/form-atoms" 10 | import { AnimatePresence, motion } from "framer-motion" 11 | import { useAtom, useAtomValue } from "jotai" 12 | 13 | import Post from "../post/post" 14 | import { Button } from "../ui/button" 15 | import Spinner from "../ui/spinner" 16 | 17 | const containerVariants = { 18 | initial: { 19 | opacity: 0, 20 | y: 20, 21 | }, 22 | exit: { 23 | opacity: 0, 24 | y: 20, 25 | }, 26 | animate: { 27 | opacity: 1, 28 | y: 0, 29 | }, 30 | } 31 | 32 | const Step3 = () => { 33 | const handling = useAtomValue(handlingAtom) 34 | const [step, stepAction] = useAtom(stepHandlerAtom) 35 | const postError = useAtomValue(postErrorAtom) 36 | const [post, setPost] = useAtom(postAtom) 37 | const handlingMessage = useAtomValue(handlingMessageAtom) 38 | const goBackHandler = () => { 39 | stepAction("dec") 40 | setTimeout(() => { 41 | setPost({ title: "", content: "" }) 42 | }, 500) 43 | } 44 | return ( 45 | 52 | 53 | {handling || (!post.content && !postError) ? ( 54 | 61 |
62 | 63 |
{handlingMessage}
64 |
65 |
66 | ) : !postError ? ( 67 | 74 |
75 | Post 76 |
77 |
78 |

79 | {post.title} 80 |

81 | 82 |
83 | {/* Button */} 84 | 91 |
92 | ) : ( 93 | 100 |
101 | Error 102 |
103 | {JSON.stringify(postError)} 104 |
105 | )} 106 |
107 |
108 | ) 109 | } 110 | 111 | export default Step3 112 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | import { ThemeProviderProps } from "next-themes/dist/types" 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useTheme } from "next-themes" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { Icons } from "@/components/icons" 8 | 9 | export function ThemeToggle() { 10 | const { setTheme, theme } = useTheme() 11 | 12 | return ( 13 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { VariantProps, cva } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "underline-offset-4 hover:underline text-primary", 20 | }, 21 | size: { 22 | default: "h-10 py-2 px-4", 23 | sm: "h-9 px-3 rounded-md", 24 | lg: "h-11 px-8 rounded-md", 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: "default", 29 | size: "default", 30 | }, 31 | } 32 | ) 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps {} 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, ...props }, ref) => { 40 | return ( 41 |