├── .env.example ├── .github └── nathan-s-ai.gif ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── (chat) │ ├── conversations │ │ ├── [conversationId] │ │ │ ├── loading.tsx │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ └── page.tsx ├── env.ts ├── globals.css ├── layout.tsx └── opengraph-image.png ├── bun.lockb ├── commitlint.config.ts ├── components.json ├── components ├── chat.tsx ├── code-block.tsx ├── content.tsx ├── conversation.tsx ├── conversations-sidebar.tsx ├── empty-conversation.tsx ├── example-questions.tsx ├── filters-popover.tsx ├── info-dialog.tsx ├── loader.tsx ├── markdown.tsx ├── message.tsx ├── prompt-form.tsx ├── theme │ ├── theme-customizer.tsx │ └── theme-provider.tsx └── ui │ ├── alert.tsx │ ├── animate-state.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── command.tsx │ ├── dazzle │ ├── index.tsx │ └── style.css │ ├── dialog.tsx │ ├── fancy-button.tsx │ ├── icons.tsx │ ├── input.tsx │ ├── label.tsx │ ├── multi-select-combobox.tsx │ ├── popover.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ └── tooltip.tsx ├── config └── site-config.ts ├── content ├── about │ ├── me.mdx │ └── tech-stack.mdx ├── awards │ ├── pst-2022.mdx │ ├── pst-2024.mdx │ └── roborave-2022.mdx ├── certifications │ ├── advanced-react.mdx │ ├── graph-developer.mdx │ └── react-basics.mdx ├── educations │ ├── centria.mdx │ ├── esiea.mdx │ └── mid-sweden.mdx ├── experiences │ ├── dnb-summer-intern-1.mdx │ ├── dnb-summer-intern-2.mdx │ └── dnb-summer-intern-3.mdx ├── languages │ ├── english.mdx │ ├── french.mdx │ ├── norwegian.mdx │ └── swedish.mdx ├── projects │ ├── b-moveon.mdx │ ├── chat-admin-panel.mdx │ ├── chat.mdx │ ├── esieabot.mdx │ ├── grammar-checker.mdx │ ├── portfolio.mdx │ └── write.mdx ├── recommendations │ ├── kamal.mdx │ └── maja.mdx └── volunteerings │ ├── assistant.mdx │ ├── education-assistant.mdx │ ├── robotics-educator.mdx │ └── stem-educator.mdx ├── contentlayer.config.ts ├── drizzle.config.ts ├── eslint.config.mjs ├── hooks ├── use-ai.ts ├── use-animated-text.tsx ├── use-enter-submit.tsx ├── use-mobile.tsx └── use-vibrate.tsx ├── lib ├── chat │ ├── actions.tsx │ ├── prompt.ts │ └── types.ts ├── db │ ├── actions.ts │ ├── index.ts │ └── schema.ts ├── questions │ ├── actions.ts │ └── types.ts ├── rate-limit.ts └── utils.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── public ├── fonts │ └── CalSans-SemiBold.woff2 ├── logo-dark.svg └── logo.svg ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Anthropic API 2 | ANTHROPIC_API_KEY="" 3 | 4 | # Vercel KV Database 5 | KV_URL="" 6 | KV_REST_API_URL="" 7 | KV_REST_API_TOKEN="" 8 | KV_REST_API_READ_ONLY_TOKEN="" 9 | 10 | # Vercel Postgres Database 11 | POSTGRES_URL="" 12 | -------------------------------------------------------------------------------- /.github/nathan-s-ai.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanBrodin/Chat/7982bfa6657cd7a9c7f67098c6011b1fa59c1924/.github/nathan-s-ai.gif -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # Sentry Config File 39 | .env.sentry-build-plugin 40 | 41 | .contentlayer 42 | 43 | public/static 44 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | bunx commitlint --edit ${1} -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | bun lint 2 | bun prettier 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "printWidth": 120, 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to chat.brodin.dev. I am happy to have you here. 4 | 5 | Please take a moment to review this document before submitting your first pull request. I also strongly recommend that you check for open issues and pull requests to see if someone else is working on something similar. 6 | 7 | If you need any help, feel free to reach out to [@me](https://brodin.dev/contact). 8 | 9 | ## Development 10 | 11 | ### Fork this repo 12 | 13 | You can fork this repo by clicking the fork button in the top right corner of this page. 14 | 15 | ### Clone on your local machine 16 | 17 | ```bash 18 | git clone https://github.com/your-username/chat.git 19 | ``` 20 | 21 | ### Navigate to project directory 22 | 23 | ```bash 24 | cd chat 25 | ``` 26 | 27 | ### Create a new Branch 28 | 29 | ```bash 30 | git checkout -b my-new-branch 31 | ``` 32 | 33 | ### Install dependencies 34 | 35 | ```bash 36 | bun install 37 | ``` 38 | 39 | ### Run the app 40 | 41 | ```bash 42 | bun run dev 43 | ``` 44 | 45 | Refer to [package.json](./package.json) to see all available commands 46 | 47 | ## Commit Convention 48 | 49 | Before you create a Pull Request, please check whether your commits comply with 50 | the commit conventions used in this repository. 51 | 52 | When you create a commit we kindly ask you to follow the convention 53 | `category(scope or module): message` in your commit message while using one of 54 | the following categories: 55 | 56 | - `feat / feature`: all changes that introduce completely new code or new 57 | features 58 | - `fix`: changes that fix a bug (ideally you will additionally reference an 59 | issue if present) 60 | - `refactor`: any code related change that is not a fix nor a feature 61 | - `docs`: changing existing or creating new documentation (i.e. README, docs for 62 | usage of a lib or cli usage) 63 | - `build`: all changes regarding the build of the software, changes to 64 | dependencies or the addition of new dependencies 65 | - `test`: all changes regarding tests (adding new tests or changing existing 66 | ones) 67 | - `ci`: all changes regarding the configuration of continuous integration (i.e. 68 | github actions, ci system) 69 | - `chore`: all changes to the repository that do not fit into any of the above 70 | categories 71 | 72 | e.g. `feat(components): add new prop to the avatar component` 73 | 74 | If you are interested in the detailed specification you can visit 75 | https://www.conventionalcommits.org/ or check out the 76 | [Angular Commit Message Guidelines](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines). 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nathan Brodin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Nathan's AI demo 3 |

Nathan's AI

4 |
5 | 6 |

7 | It’s my portfolio, reimagined as a chat conversation, answered by AI. 8 |

9 | 10 |

11 | Tech Stack · 12 | Sources of Inspiration · 13 | Deploy Your Own · 14 | Running locally 15 |

16 |
17 | 18 | Nathan's AI is a unique twist on the traditional portfolio. Instead of scrolling through pages of information, visitors can simply ask questions to learn about my career, skills, projects, and experiences. Built with Next.js, Tailwind CSS, and Vercel’s AI SDK, this chatbot acts as an interactive resume, letting you explore my journey in a conversational way. 19 | 20 | ## Tech Stack 21 | 22 | - [Next.js](https://nextjs.org) App Router 23 | - Advanced routing for seamless navigation and performance 24 | - React Server Components (RSCs) and Server Actions for server-side rendering and increased performance 25 | - [AI SDK](https://sdk.vercel.ai/docs) 26 | - Unified API for generating text, structured objects, and tool calls with LLMs 27 | - Supports Anthropic (default), OpenAI, Cohere, and other model providers 28 | - [shadcn/ui](https://ui.shadcn.com) 29 | - Styling with [Tailwind CSS](https://tailwindcss.com) 30 | - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility 31 | - Data Persistence 32 | - [Vercel Postgres powered by Neon](https://vercel.com/storage/postgres) for saving chat history 33 | - [@upstash/ratelimit](https://upstash.com/docs/oss/sdks/ts/ratelimit/overview) 34 | - Preventing excessive usage of the chat 35 | - [motion](https://motion.dev) 36 | - A modern animation library for JavaScript and React 37 | - Clean and easy to use animations 38 | 39 | ## Sources of Inspiration 40 | 41 | I am proud of my designs, but it doesn't came from the pure source of my imagination. Here you can find links of website I used to create my own design. 42 | 43 | - Empty Screen messages: [Cal.com](https://cal.com/) 44 | - Messages animation: [Build UI](https://buildui.com/recipes/animated-list) 45 | - Title animation: [@jh3yy](https://x.com/jh3yy/status/1849062440773820747) 46 | - Themes: [ui/jln](https://ui.jln.dev/) 47 | 48 | ## Deploy Your Own 49 | 50 | You can deploy your own version of Nathan's AI Chatbot to Vercel with one click: 51 | 52 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnathanbrodin%2Fchat&env=ANTHROPIC_API_KEY,KV_URL,KV_REST_API_URL,KV_REST_API_TOKEN,KV_REST_API_READ_ONLY_TOKEN&demo-title=Nathan's%20AI&demo-description=Curious%20about%20Nathan%20Brodin%3F%20Ask%20his%20AI%20anything!&demo-url=https%3A%2F%2Fchat.brodin.dev) 53 | 54 | ### Setting up the chat's services 55 | 56 | Nathan's AI depends on multiple services to function properly, and as you saw by deploying to Vercel, you need a few environment variables to make it work. Follow these steps to configure the necessary environment variables. 57 | 58 | #### 1. AI Provider (Anthropic API) 59 | 60 | Grab your [Anthropic API Key](https://console.anthropic.com/settings/keys) and paste it into your `.env.local` file: 61 | 62 | ``` 63 | ANTHROPIC_API_KEY="your-api-key-here" 64 | ``` 65 | 66 | Nathan's AI currently uses Anthropic, but you can switch to another provider by updating the model inside `streamText()` in [lib/chat/actions.tsx](./lib/chat/actions.tsx). For more details, check out the [AI SDK documentation](https://sdk.vercel.ai/docs/foundations/providers-and-models). If you switch providers, remember to update the relevant environment variables accordingly. 67 | 68 | #### 2. Rate Limiting (Upstash) 69 | 70 | To prevent users (or bots) from consuming all your AI credits in a caffeine-fueled chat spree, we use Upstash for rate limiting. 71 | 72 | 1. Create a Redis database on [Upstash](https://upstash.com/docs/redis/overall/getstarted). 73 | 2. Add these variables to your `.env.local` file: 74 | 75 | ``` 76 | KV_URL="your-kv-url" 77 | KV_REST_API_URL="your-rest-api-url" 78 | KV_REST_API_TOKEN="your-api-token" 79 | KV_REST_API_READ_ONLY_TOKEN="your-read-only-token" 80 | ``` 81 | 82 | #### 3. Chat Storage (Neon Postgres) 83 | 84 | If you want to save chat logs for fine-tuning responses (definitely not because you're nosy), you'll need a Postgres database. 85 | 86 | 1. Create a Postgres database on [Neon](https://neon.tech/). 87 | 2. Add your connection string to `.env.local`: 88 | 89 | ``` 90 | POSTGRES_URL="your-database-url" 91 | ``` 92 | 93 | 94 | ### Updating the AI Content 95 | 96 | Customize the AI’s responses by replacing all content inside the [./content/\*\*](./content/) directory with your own experiences, education, or whatever makes your AI unique. 97 | 98 | The content structure is defined in: 99 | 100 | - [contentlayer.config.ts](./contentlayer.config.ts) 101 | - [lib/chat/types.ts](lib/chat/types.ts) 102 | 103 | Adapt these files as needed to fit your requirements. 104 | 105 | ### Renaming Nathan's AI 106 | 107 | If your name happens to be Nathan, congratulations! The chatbot is already personalized for you. No changes needed. 108 | 109 | For everyone else, you'll need to update all occurrences of its current name. 110 | 111 | #### VIM Users: 112 | 113 | Run a simple: 114 | 115 | ``` 116 | g/Nathan's AI/ 117 | ``` 118 | 119 | This will show all occurrences so you can update them efficiently. 120 | 121 | #### Non-VIM Users: 122 | 123 | That's not my problem sorry, find your way's to replace it. 124 | 125 | ## Running locally 126 | 127 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Nathan's AI locally. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary. 128 | 129 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various authentication provider accounts. 130 | 131 | 1. Install Vercel CLI: `npm i -g vercel` 132 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` 133 | 3. Download your environment variables: `vercel env pull` 134 | 135 | ```bash 136 | bun install 137 | ``` 138 | 139 | ```bash 140 | bun run dev 141 | ``` 142 | 143 | That's it, you are all set! 144 | If you run into any problems or have any questions, please hesitate to ask me. 145 | -------------------------------------------------------------------------------- /app/(chat)/conversations/[conversationId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "@/components/loader" 2 | import { Message } from "@/components/message" 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 | , 11 | role: "user", 12 | }} 13 | /> 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/(chat)/conversations/[conversationId]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | export default function NotFound() { 4 | return ( 5 |
6 |

Not Found

7 |

I don't know what you where looking for, but it's not available!

8 | Return Home 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /app/(chat)/conversations/[conversationId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Content } from "@/components/content" 2 | import { Message } from "@/components/message" 3 | import { getMessages } from "@/lib/db/actions" 4 | 5 | export default async function ConversationPage({ params }: { params: Promise<{ conversationId: string }> }) { 6 | const conversationId = (await params).conversationId 7 | 8 | const messages = await getMessages(conversationId) 9 | 10 | return ( 11 |
12 | {messages.map((message) => ( 13 | }} 15 | key={message.id} 16 | /> 17 | ))} 18 | {messages.length === 0 && ( 19 |
20 |
21 |
No messages
22 |
in this conversation
23 |
24 |
25 | )} 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/(chat)/conversations/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ConversationsSidebar } from "@/components/conversations-sidebar" 2 | import { Separator } from "@/components/ui/separator" 3 | import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" 4 | 5 | export default async function ConversationsLayout({ children }: { children: React.ReactNode }) { 6 | return ( 7 | 15 | 16 | 17 |
18 | 19 | 20 | 26 | You curious one, stop peeking at other peoples conversations 27 | 28 |
29 |
{children}
30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/(chat)/conversations/page.tsx: -------------------------------------------------------------------------------- 1 | export default function ConversationsPage() { 2 | return ( 3 |
4 |
5 |
Select a conversation
6 |
and be as curious as you want!
7 |
8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /app/(chat)/page.tsx: -------------------------------------------------------------------------------- 1 | import { generateId } from "ai" 2 | import Chat from "@/components/chat" 3 | import InfoDialog from "@/components/info-dialog" 4 | import { AI } from "@/lib/chat/actions" 5 | import { getQuestions } from "@/lib/questions/actions" 6 | import { searchParamsToGeo } from "@/lib/utils" 7 | 8 | // Force the page to be dynamic and allow streaming responses up to 30 seconds 9 | export const dynamic = "force-dynamic" 10 | export const maxDuration = 30 11 | 12 | type PageProps = { 13 | searchParams: Promise<{ [key: string]: string | string[] | undefined }> 14 | } 15 | 16 | export default async function Home(props: PageProps) { 17 | const searchParams = await props.searchParams 18 | const location = searchParamsToGeo(searchParams) 19 | const questions = await getQuestions(location) 20 | 21 | return ( 22 |
23 | 24 | 25 | 26 |
27 | 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-core" 2 | import { z } from "zod" 3 | 4 | export const env = createEnv({ 5 | server: { 6 | ANTHROPIC_API_KEY: z.string().min(1), 7 | POSTGRES_URL: z.string().min(1), 8 | KV_URL: z.string().min(1), 9 | KV_REST_API_URL: z.string().min(1), 10 | KV_REST_API_TOKEN: z.string().min(1), 11 | KV_REST_API_READ_ONLY_TOKEN: z.string().min(1), 12 | }, 13 | 14 | /** 15 | * The prefix that client-side variables must have. This is enforced both at 16 | * a type-level and at runtime. 17 | */ 18 | clientPrefix: "PUBLIC_", 19 | client: {}, 20 | 21 | runtimeEnv: process.env, 22 | emptyStringAsUndefined: true, 23 | }) 24 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 220.47 98.26% 36.08%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 92.99% 56.11%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 220.67 97.83% 36.08%; 34 | 35 | --radius: 0.5rem; 36 | 37 | --sidebar-background: 0 0% 98%; 38 | --sidebar-foreground: 240 5.3% 26.1%; 39 | --sidebar-primary: 240 5.9% 10%; 40 | --sidebar-primary-foreground: 0 0% 98%; 41 | --sidebar-accent: 240 4.8% 95.9%; 42 | --sidebar-accent-foreground: 240 5.9% 10%; 43 | --sidebar-border: 220 13% 91%; 44 | --sidebar-ring: 217.2 91.2% 59.8%; 45 | } 46 | 47 | .dark { 48 | --background: 0 0% 6.27%; 49 | --foreground: 252 37% 98%; 50 | 51 | --card: 0 0% 6.27%; 52 | --card-foreground: 252 37% 99%; 53 | 54 | --popover: 0 0% 8.63%; 55 | --popover-foreground: 252 37% 99%; 56 | 57 | --primary: 27.06 100% 80%; 58 | --primary-foreground: 0 0% 0%; 59 | 60 | --secondary: 164.12 100% 80%; 61 | --secondary-foreground: 0 0% 0%; 62 | 63 | --muted: 0 0% 10.98%; 64 | --muted-foreground: 0 0% 62.75%; 65 | 66 | --accent: 27.06 100% 80%; 67 | --accent-foreground: 0 0% 0%; 68 | 69 | --destructive: 0 21.31% 11.96%; 70 | --destructive-foreground: 0 100% 75.1%; 71 | 72 | --border: 0 0% 15.69%; 73 | --input: 0 0% 20%; 74 | --ring: 27.06 100% 80%; 75 | 76 | --sidebar-background: 240 5.9% 10%; 77 | --sidebar-foreground: 240 4.8% 95.9%; 78 | --sidebar-primary: 224.3 76.3% 48%; 79 | --sidebar-primary-foreground: 0 0% 100%; 80 | --sidebar-accent: 240 3.7% 15.9%; 81 | --sidebar-accent-foreground: 240 4.8% 95.9%; 82 | --sidebar-border: 240 3.7% 15.9%; 83 | --sidebar-ring: 217.2 91.2% 59.8%; 84 | } 85 | } 86 | 87 | @layer base { 88 | * { 89 | @apply border-border; 90 | } 91 | body { 92 | @apply bg-background font-sans text-foreground; 93 | } 94 | } 95 | 96 | .text-shadow-primary { 97 | color: hsl(var(--primary)); 98 | text-shadow: 0 2px 0 #c6c6c6; 99 | } 100 | 101 | .dark .text-shadow-primary { 102 | text-shadow: 0 2px 0 #494949; 103 | } 104 | 105 | .text-shadow-accent { 106 | --text-border-width: 2px; 107 | -webkit-text-fill-color: hsl(var(--background)); 108 | -webkit-text-stroke-width: var(--text-border-width); 109 | -webkit-text-stroke-color: hsl(var(--foreground)); 110 | text-shadow: 0 2px 0 #141414; 111 | } 112 | 113 | .dark .text-shadow-accent { 114 | text-shadow: 0 2px 0 #f7f7f7; 115 | } 116 | 117 | .sidebar a.selected-conversation, 118 | .sidebar a.selected-conversation:visited { 119 | background-color: var(--background); 120 | color: var(--primary); 121 | } 122 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from "@vercel/analytics/react" 2 | import { SpeedInsights } from "@vercel/speed-insights/next" 3 | import { GeistSans } from "geist/font/sans" 4 | import type { Metadata } from "next" 5 | import "./globals.css" 6 | import localFont from "next/font/local" 7 | import { ThemeProvider } from "@/components/theme/theme-provider" 8 | import { siteConfig } from "@/config/site-config" 9 | 10 | const CalSans = localFont({ 11 | src: "../public/fonts/CalSans-SemiBold.woff2", 12 | variable: "--font-calsans", 13 | }) 14 | 15 | export const metadata: Metadata = { 16 | title: siteConfig.title, 17 | description: siteConfig.description, 18 | creator: siteConfig.name, 19 | authors: [ 20 | { 21 | name: siteConfig.name, 22 | url: new URL(siteConfig.authorUrl), 23 | }, 24 | ], 25 | applicationName: siteConfig.title, 26 | metadataBase: new URL(siteConfig.url), 27 | openGraph: { 28 | type: "website", 29 | locale: "en_US", 30 | url: new URL(siteConfig.url), 31 | title: siteConfig.title, 32 | description: siteConfig.description, 33 | siteName: siteConfig.title, 34 | }, 35 | twitter: { 36 | card: "summary_large_image", 37 | title: siteConfig.title, 38 | description: siteConfig.description, 39 | creator: siteConfig.twitterHandle, 40 | }, 41 | keywords: siteConfig.keywords, 42 | icons: { 43 | icon: [ 44 | { 45 | media: "(prefers-color-scheme: light)", 46 | url: "/logo.svg", 47 | href: "/logo.svg", 48 | }, 49 | { 50 | media: "(prefers-color-scheme: dark)", 51 | url: "/logo-dark.svg", 52 | href: "/logo-dark.svg", 53 | }, 54 | ], 55 | }, 56 | } 57 | 58 | export default function RootLayout({ 59 | children, 60 | }: Readonly<{ 61 | children: React.ReactNode 62 | }>) { 63 | return ( 64 | 65 | 66 | 67 | {children} 68 | 69 | 70 | 71 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanBrodin/Chat/7982bfa6657cd7a9c7f67098c6011b1fa59c1924/app/opengraph-image.png -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanBrodin/Chat/7982bfa6657cd7a9c7f67098c6011b1fa59c1924/bun.lockb -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-anonymous-default-export 2 | export default { extends: ["@commitlint/config-conventional"] } 3 | 4 | // build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) 5 | // chore: Other changes that don't modify src or test files 6 | // ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) 7 | // docs: Documentation only changes 8 | // feat: A new feature 9 | // fix: A bug fix 10 | // perf: A code change that improves performance 11 | // refactor: A code change that neither fixes a bug nor adds a feature 12 | // revert: Reverts a previous commit 13 | // style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 14 | // test: Adding missing tests or correcting existing tests 15 | // translation: Translation updates 16 | // security: Security updates 17 | // changeset: Changeset updates 18 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/chat.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Geo } from "@vercel/edge" 4 | import { generateId } from "ai" 5 | import { readStreamableValue } from "ai/rsc" 6 | import { AnimatePresence } from "motion/react" 7 | import { useState } from "react" 8 | import { Conversation } from "@/components/conversation" 9 | import { EmptyConversation } from "@/components/empty-conversation" 10 | import { PromptForm } from "@/components/prompt-form" 11 | import { Separator } from "@/components/ui/separator" 12 | import { useActions, useUIState } from "@/hooks/use-ai" 13 | import { UIState } from "@/lib/chat/types" 14 | import { Question } from "@/lib/questions/types" 15 | import { Content } from "./content" 16 | import InfoDialog from "./info-dialog" 17 | import { Loader } from "./loader" 18 | import { useVibration } from "@/hooks/use-vibrate" 19 | import { Button } from "./ui/button" 20 | 21 | type ChatProps = { 22 | location: Geo 23 | questions: Question[] 24 | } 25 | 26 | export default function Chat({ questions, location }: ChatProps) { 27 | const vibrate = useVibration() 28 | const [messages, setMessages] = useUIState() 29 | const { continueConversation } = useActions() 30 | const [isLoading, setIsLoading] = useState(false) 31 | const [isError, setIsError] = useState(false) 32 | 33 | async function addMessage(input: string) { 34 | const value = input.trim() 35 | if (!value) return 36 | 37 | // Add user message to the state 38 | const newMessages: UIState = [...messages, { id: generateId(), display: value, role: "user" }] 39 | 40 | setMessages(newMessages) 41 | setIsLoading(true) 42 | 43 | // Add a placeholder assistant message 44 | const assistantMessageId = generateId() 45 | setMessages((prev) => [ 46 | ...prev, 47 | { 48 | id: assistantMessageId, 49 | display: , 50 | role: "assistant", 51 | }, 52 | ]) 53 | 54 | // Add a delay before the second vibration 55 | setTimeout(() => { 56 | // Add vibration when streaming begins 57 | vibrate() 58 | }, 200) // 200ms delay to make it distinct from the first vibration 59 | 60 | try { 61 | // Get the assistant's response 62 | const result = await continueConversation(value, location) 63 | 64 | let textContent = "" 65 | 66 | for await (const delta of readStreamableValue(result)) { 67 | textContent = `${textContent}${delta}` 68 | 69 | setMessages([ 70 | ...newMessages, 71 | { id: assistantMessageId, role: "assistant", display: }, 72 | ]) 73 | } 74 | } catch (error) { 75 | setIsError(true) 76 | setMessages([ 77 | ...newMessages, 78 | { 79 | id: assistantMessageId, 80 | role: "error", 81 | display: 82 | (error as Error).message === "Rate limit exceeded" 83 | ? "Whoa, easy there big talker! You've hit the rate limit. Give it a moment before asking more." 84 | : "Oops, something went wrong! Maybe it's a server error (unlikely, I never make mistakes), an issue with my AI provider, or... I might be out of AI credits. Sad times :( Try refreshing the page, it always works.", 85 | }, 86 | ]) 87 | } 88 | 89 | // Add vibration when streaming ends 90 | vibrate() 91 | 92 | setIsLoading(false) 93 | } 94 | 95 | return ( 96 |
97 | 98 | {messages.length === 0 && } 99 | 100 | 101 | 102 |
103 | 104 | 105 |
106 |
107 |
108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /components/code-block.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import React, { useEffect, useState } from "react" 5 | import { codeToHtml } from "shiki" 6 | 7 | export type CodeBlockProps = { 8 | children?: React.ReactNode 9 | className?: string 10 | } & React.HTMLProps 11 | 12 | function CodeBlock({ children, className, ...props }: CodeBlockProps) { 13 | return ( 14 |
22 | {children} 23 |
24 | ) 25 | } 26 | 27 | export type CodeBlockCodeProps = { 28 | code: string 29 | language?: string 30 | theme?: string 31 | className?: string 32 | } & React.HTMLProps 33 | 34 | function CodeBlockCode({ code, language = "tsx", theme = "github-light", className, ...props }: CodeBlockCodeProps) { 35 | const [highlightedHtml, setHighlightedHtml] = useState(null) 36 | 37 | useEffect(() => { 38 | async function highlight() { 39 | const html = await codeToHtml(code, { lang: language, theme }) 40 | setHighlightedHtml(html) 41 | } 42 | highlight() 43 | }, [code, language, theme]) 44 | 45 | const classNames = cn("w-full overflow-x-auto text-[13px] [&>pre]:px-4 [&>pre]:py-4", className) 46 | 47 | // SSR fallback: render plain code if not hydrated yet 48 | return highlightedHtml ? ( 49 |
50 | ) : ( 51 |
52 |
53 |         {code}
54 |       
55 |
56 | ) 57 | } 58 | 59 | export type CodeBlockGroupProps = React.HTMLAttributes 60 | 61 | function CodeBlockGroup({ children, className, ...props }: CodeBlockGroupProps) { 62 | return ( 63 |
64 | {children} 65 |
66 | ) 67 | } 68 | 69 | export { CodeBlockGroup, CodeBlockCode, CodeBlock } 70 | -------------------------------------------------------------------------------- /components/content.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import dynamic from "next/dynamic" 4 | import { useAnimatedText } from "@/hooks/use-animated-text" 5 | 6 | const Markdown = dynamic(() => import("./markdown").then((mod) => mod.Markdown), { ssr: false }) 7 | 8 | type ContentProps = { 9 | content: string 10 | duration?: number 11 | } 12 | 13 | export function Content({ content, duration }: ContentProps) { 14 | const text = useAnimatedText(content, duration) 15 | 16 | return ( 17 | 18 | {text} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/conversation.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { motion } from "motion/react" 4 | import { useEffect, useRef } from "react" 5 | import { UIState } from "@/lib/chat/types" 6 | import { Message } from "./message" 7 | 8 | type ConversationProps = { 9 | messages: UIState 10 | } 11 | 12 | export function Conversation({ messages }: ConversationProps) { 13 | const listRef = useRef(null) 14 | 15 | useEffect(() => { 16 | if (listRef.current) { 17 | listRef.current.scrollTop = listRef.current.scrollHeight 18 | } 19 | }, [messages]) 20 | 21 | return ( 22 |
26 |
27 |
28 | {messages.map((message) => ( 29 | 38 | 39 | 40 | ))} 41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /components/conversations-sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { format } from "date-fns" 4 | import Link from "next/link" 5 | import { useParams } from "next/navigation" 6 | import { 7 | Sidebar, 8 | SidebarContent, 9 | SidebarFooter, 10 | SidebarGroup, 11 | SidebarGroupContent, 12 | SidebarHeader, 13 | } from "@/components/ui/sidebar" 14 | import { cn, getCountryName } from "@/lib/utils" 15 | import { Button } from "./ui/button" 16 | import { useEffect, useRef, useState } from "react" 17 | import { getConversations } from "@/lib/db/actions" 18 | import { Loader } from "./loader" 19 | import { Filters, FiltersPopover } from "./filters-popover" 20 | 21 | type Conversation = { 22 | id: string 23 | city: string | null 24 | country: string | null 25 | createdAt: Date | null 26 | preview: string | null 27 | } 28 | 29 | export function ConversationsSidebar() { 30 | const params = useParams() 31 | const conversationId = params.conversationId 32 | 33 | // Infinite scroll 34 | const [conversations, setConversations] = useState([]) 35 | const [page, setPage] = useState(1) 36 | const [loading, setLoading] = useState(false) 37 | const [totalCount, setTotalCount] = useState(0) 38 | const [hasMore, setHasMore] = useState(true) 39 | const [initialLoad, setInitialLoad] = useState(true) 40 | const observerTarget = useRef(null) 41 | 42 | const [filters, setFilters] = useState({ 43 | countries: [], 44 | dateRange: null, 45 | }) 46 | 47 | // Function to apply filters and reset pagination 48 | function handleApplyFilters(newFilters: Filters) { 49 | setFilters(newFilters) 50 | // Reset to page 1 when applying new filters 51 | setPage(1) 52 | setConversations([]) 53 | setHasMore(true) 54 | } 55 | 56 | useEffect(() => { 57 | const fetchConversations = async () => { 58 | if (!hasMore || loading) return 59 | 60 | setLoading(true) 61 | try { 62 | const countryValues = filters.countries.map((item) => item.value) 63 | const dateRange = filters.dateRange?.value 64 | 65 | // Pass these values to your getConversations function 66 | const result = await getConversations({ 67 | page, 68 | countries: countryValues.length > 0 ? countryValues : undefined, 69 | dateRange: dateRange || undefined, 70 | }) 71 | 72 | // For page 1, replace the posts array; for subsequent pages, append 73 | if (page === 1) { 74 | setConversations(result.conversations) 75 | } else { 76 | setConversations((prev) => [...prev, ...result.conversations]) 77 | } 78 | 79 | setTotalCount(result.totalCount) 80 | setHasMore(result.hasMore) 81 | setInitialLoad(false) 82 | } catch (error) { 83 | console.error("Error fetching posts:", error) 84 | } finally { 85 | setLoading(false) 86 | } 87 | } 88 | 89 | fetchConversations() 90 | }, [page]) 91 | 92 | useEffect(() => { 93 | // Don't set up observer during initial load 94 | if (initialLoad) return 95 | 96 | const observer = new IntersectionObserver( 97 | (entries) => { 98 | if (entries[0].isIntersecting && hasMore && !loading) { 99 | setPage((prevPage) => prevPage + 1) 100 | } 101 | }, 102 | { threshold: 1.0 } 103 | ) 104 | 105 | const currentTarget = observerTarget.current 106 | if (currentTarget) { 107 | observer.observe(currentTarget) 108 | } 109 | 110 | return () => { 111 | if (currentTarget) { 112 | observer.unobserve(currentTarget) 113 | } 114 | } 115 | }, [hasMore, loading, initialLoad]) 116 | 117 | return ( 118 | 119 | 120 | Conversations 121 | 122 | 123 | 124 | 125 |
126 | 127 |
{totalCount} results
128 |
129 | {conversations.map((conversation) => ( 130 | 138 |
141 | 142 | {conversation.city ? decodeURIComponent(conversation.city) : ""} 143 | {conversation.country ? `, ${getCountryName(conversation.country)}` : ""} 144 | 145 | {format(conversation.createdAt!, "dd MMM yyyy, HH:mm")} 146 |
147 | 153 | {conversation.preview} 154 | 155 | 156 | ))} 157 | 158 | {loading && ( 159 |
160 | 161 |
162 | )} 163 | 164 | {hasMore &&
} 165 | {!hasMore && conversations.length > 0 && ( 166 |

That's all!

167 | )} 168 | 169 | 170 | 171 | 172 | 175 | 176 | 177 | ) 178 | } 179 | -------------------------------------------------------------------------------- /components/empty-conversation.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "motion/react" 2 | import { Question } from "@/lib/questions/types" 3 | import { ExampleQuestions } from "./example-questions" 4 | import Dazzle from "./ui/dazzle" 5 | import { IconNathansAI } from "./ui/icons" 6 | 7 | type EmptyConversationProps = { 8 | questions: Question[] 9 | addMessage: (input: string) => Promise 10 | } 11 | 12 | export function EmptyConversation({ questions, addMessage }: EmptyConversationProps) { 13 | return ( 14 | 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/example-questions.tsx: -------------------------------------------------------------------------------- 1 | import { Question } from "@/lib/questions/types" 2 | import { FancyButton } from "./ui/fancy-button" 3 | import { useVibration } from "@/hooks/use-vibrate" 4 | 5 | type ExampleQuestionsProps = { 6 | questions: Question[] 7 | addMessage: (input: string) => Promise 8 | } 9 | 10 | export function ExampleQuestions({ questions, addMessage }: ExampleQuestionsProps) { 11 | const vibrate = useVibration() 12 | 13 | return ( 14 |
15 | {questions.map((q) => ( 16 | { 19 | vibrate() 20 | addMessage(q.content) 21 | }} 22 | > 23 | {q.content} 24 | 25 | ))} 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/filters-popover.tsx: -------------------------------------------------------------------------------- 1 | import { Check, SlidersHorizontalIcon, XIcon } from "lucide-react" 2 | import { Button } from "./ui/button" 3 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover" 4 | import { useEffect, useState } from "react" 5 | import { Item, MultiSelectCombobox } from "./ui/multi-select-combobox" 6 | import { Badge } from "./ui/badge" 7 | import { AnimatePresence, motion } from "motion/react" 8 | import { Command, CommandGroup, CommandItem, CommandList } from "./ui/command" 9 | import { cn } from "@/lib/utils" 10 | 11 | // Country data 12 | const countries = [ 13 | { value: "AR", label: "Argentina" }, 14 | { value: "AU", label: "Australia" }, 15 | { value: "AT", label: "Austria" }, 16 | { value: "BD", label: "Bangladesh" }, 17 | { value: "BA", label: "Bosnia and Herzegovina" }, 18 | { value: "BR", label: "Brazil" }, 19 | { value: "BG", label: "Bulgaria" }, 20 | { value: "CM", label: "Cameroon" }, 21 | { value: "CA", label: "Canada" }, 22 | { value: "CL", label: "Chile" }, 23 | { value: "CN", label: "China" }, 24 | { value: "CZ", label: "Czech Republic" }, 25 | { value: "DK", label: "Denmark" }, 26 | { value: "EG", label: "Egypt" }, 27 | { value: "ET", label: "Ethiopia" }, 28 | { value: "FI", label: "Finland" }, 29 | { value: "FR", label: "France" }, 30 | { value: "DE", label: "Germany" }, 31 | { value: "HK", label: "Hong Kong" }, 32 | { value: "IN", label: "India" }, 33 | { value: "ID", label: "Indonesia" }, 34 | { value: "IE", label: "Ireland" }, 35 | { value: "IL", label: "Israel" }, 36 | { value: "IT", label: "Italy" }, 37 | { value: "JP", label: "Japan" }, 38 | { value: "XK", label: "Kosovo" }, 39 | { value: "LV", label: "Latvia" }, 40 | { value: "LB", label: "Lebanon" }, 41 | { value: "MG", label: "Madagascar" }, 42 | { value: "MY", label: "Malaysia" }, 43 | { value: "MX", label: "Mexico" }, 44 | { value: "MD", label: "Moldova" }, 45 | { value: "NL", label: "Netherlands" }, 46 | { value: "NP", label: "Nepal" }, 47 | { value: "NZ", label: "New Zealand" }, 48 | { value: "NG", label: "Nigeria" }, 49 | { value: "NO", label: "Norway" }, 50 | { value: "PK", label: "Pakistan" }, 51 | { value: "PA", label: "Panama" }, 52 | { value: "PY", label: "Paraguay" }, 53 | { value: "PE", label: "Peru" }, 54 | { value: "PH", label: "Philippines" }, 55 | { value: "PL", label: "Poland" }, 56 | { value: "PT", label: "Portugal" }, 57 | { value: "RO", label: "Romania" }, 58 | { value: "RU", label: "Russia" }, 59 | { value: "SA", label: "Saudi Arabia" }, 60 | { value: "RS", label: "Serbia" }, 61 | { value: "SG", label: "Singapore" }, 62 | { value: "SI", label: "Slovenia" }, 63 | { value: "KR", label: "South Korea" }, 64 | { value: "ES", label: "Spain" }, 65 | { value: "LK", label: "Sri Lanka" }, 66 | { value: "SR", label: "Suriname" }, 67 | { value: "SE", label: "Sweden" }, 68 | { value: "CH", label: "Switzerland" }, 69 | { value: "TW", label: "Taiwan" }, 70 | { value: "TH", label: "Thailand" }, 71 | { value: "TN", label: "Tunisia" }, 72 | { value: "TR", label: "Türkiye" }, 73 | { value: "UA", label: "Ukraine" }, 74 | { value: "GB", label: "United Kingdom" }, 75 | { value: "US", label: "United States of America" }, 76 | { value: "UZ", label: "Uzbekistan" }, 77 | { value: "VN", label: "Vietnam" }, 78 | ] 79 | 80 | // Date range options 81 | const dateRanges = [ 82 | { value: "today", label: "Today" }, 83 | { value: "yesterday", label: "Yesterday" }, 84 | { value: "this-week", label: "This Week" }, 85 | { value: "last-week", label: "Last Week" }, 86 | { value: "this-month", label: "This Month" }, 87 | { value: "last-month", label: "Last Month" }, 88 | { value: "this-year", label: "This Year" }, 89 | { value: "last-year", label: "Last Year" }, 90 | ] 91 | 92 | export type Filters = { 93 | countries: Item[] 94 | dateRange: Item | null 95 | } 96 | 97 | interface FiltersPopoverProps { 98 | filters: Filters 99 | onApplyFilters: (filters: Filters) => void 100 | } 101 | 102 | export function FiltersPopover({ filters, onApplyFilters }: FiltersPopoverProps) { 103 | const [open, setOpen] = useState(false) 104 | const [tempFilters, setTempFilters] = useState({ 105 | countries: filters.countries, 106 | dateRange: filters.dateRange, 107 | }) 108 | 109 | // Update temp filters when selected filters change (for synchronization) 110 | useEffect(() => { 111 | setTempFilters({ 112 | countries: filters.countries, 113 | dateRange: filters.dateRange, 114 | }) 115 | }, [filters]) 116 | 117 | function handleCountriesSelect(items: Item[]) { 118 | setTempFilters((prev) => ({ 119 | ...prev, 120 | countries: items, 121 | })) 122 | } 123 | 124 | function handleRemoveCountry(value: string) { 125 | setTempFilters((prev) => ({ 126 | ...prev, 127 | countries: prev.countries.filter((country) => country.value !== value), 128 | })) 129 | } 130 | 131 | function handleRemoveDateRange() { 132 | setTempFilters((prev) => ({ 133 | ...prev, 134 | dateRange: null, 135 | })) 136 | } 137 | 138 | function handleRangeSelect(value: string) { 139 | const selectedRange = dateRanges.find((range) => range.value === value) 140 | 141 | if (selectedRange) { 142 | setTempFilters((prev) => { 143 | // If the same range is clicked again, deselect it 144 | if (prev?.dateRange?.value === value) { 145 | return { 146 | ...prev, 147 | dateRange: null, 148 | } 149 | } 150 | // Otherwise select the new range 151 | return { 152 | ...prev, 153 | dateRange: selectedRange, 154 | } 155 | }) 156 | } 157 | } 158 | 159 | function clearAll() { 160 | setTempFilters({ 161 | countries: [], 162 | dateRange: null, 163 | }) 164 | } 165 | 166 | function handleApplyFilters() { 167 | // Update parent state with temp filters 168 | onApplyFilters(tempFilters) 169 | // Close the popover 170 | setOpen(false) 171 | } 172 | 173 | return ( 174 | 175 | 176 | 179 | 180 | 181 |
182 | 183 | {!tempFilters.dateRange && !tempFilters.countries.length && ( 184 | 185 | No filters selected 186 | 187 | )} 188 | {tempFilters.countries.map((country) => ( 189 | 197 | 198 | {country.label} 199 | handleRemoveCountry(country.value)} /> 200 | 201 | 202 | ))} 203 | {tempFilters.dateRange && ( 204 | 211 | 212 | {tempFilters.dateRange.label} 213 | 214 | 215 | 216 | )} 217 | 218 |
219 | 220 |
221 |
222 |

Countries

223 | 229 |
230 |
231 |
232 |

Date Range

233 | 234 | 235 | 236 | {dateRanges.map((item) => ( 237 | 238 | 244 | {item.label} 245 | 246 | ))} 247 | 248 | 249 | 250 |
251 | 252 |
253 | 256 | 257 |
258 |
259 |
260 |
261 |
262 | ) 263 | } 264 | -------------------------------------------------------------------------------- /components/info-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { AlertTriangle, Github, Globe, InfoIcon, TwitterIcon, User } from "lucide-react" 2 | import Link from "next/link" 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog" 11 | import { cn } from "@/lib/utils" 12 | import { ThemeCustomizer } from "./theme/theme-customizer" 13 | import { Button } from "./ui/button" 14 | 15 | export default function InfoDialog({ className }: { className?: string }) { 16 | return ( 17 | 18 | 22 | 23 | Nathan's AI 24 | 25 | 26 | 27 | Nathan's AI 28 | Welcome to my AI portfolio! 29 | 30 |
31 |
32 |

Links

33 |
34 | 39 | 40 | Project Repo 41 | 42 | 47 | 48 | Portfolio 49 | 50 | 55 | 56 | GitHub Profile 57 | 58 | 63 | 64 | Twitter 65 | 66 |
67 |
68 |
69 |

Privacy Concerns

70 |

71 | Please be aware that all conversations are saved and visible to anyone. (Don't ask crazy questions, 72 | we see you) 73 |

74 |
75 |
76 |

How Nathan's AI Knows About Me

77 |

78 | I give to an AI model (Claude) context about my career and how it should answer your questions. I still 79 | noticed some hallucinations from the AI so don't trust everything it says. 80 |

81 |
82 | 83 |
84 | 85 | 89 | 90 |
91 |
92 |
93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /components/loader.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderIcon } from "lucide-react" 2 | 3 | type LoaderProps = { 4 | content?: string 5 | } 6 | 7 | export function Loader({ content = "Thinking..." }: LoaderProps) { 8 | return ( 9 |
10 |
{content}
11 | 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { marked } from "marked" 3 | import { memo, useId, useMemo } from "react" 4 | import ReactMarkdown, { Components } from "react-markdown" 5 | import remarkGfm from "remark-gfm" 6 | import { CodeBlock, CodeBlockCode } from "./code-block" 7 | 8 | export type MarkdownProps = { 9 | children: string 10 | id?: string 11 | className?: string 12 | components?: Partial 13 | } 14 | 15 | function parseMarkdownIntoBlocks(markdown: string): string[] { 16 | const tokens = marked.lexer(markdown) 17 | return tokens.map((token) => token.raw) 18 | } 19 | 20 | function extractLanguage(className?: string): string { 21 | if (!className) return "plaintext" 22 | const match = className.match(/language-(\w+)/) 23 | return match ? match[1] : "plaintext" 24 | } 25 | 26 | const INITIAL_COMPONENTS: Partial = { 27 | code: function CodeComponent({ className, children, ...props }) { 28 | const isInline = 29 | !props.node?.position?.start.line || props.node?.position?.start.line === props.node?.position?.end.line 30 | 31 | if (isInline) { 32 | return ( 33 | 34 | {children} 35 | 36 | ) 37 | } 38 | 39 | const language = extractLanguage(className) 40 | 41 | return ( 42 | 43 | 44 | 45 | ) 46 | }, 47 | pre: function PreComponent({ children }) { 48 | return <>{children} 49 | }, 50 | } 51 | 52 | const MemoizedMarkdownBlock = memo( 53 | function MarkdownBlock({ 54 | content, 55 | components = INITIAL_COMPONENTS, 56 | }: { 57 | content: string 58 | components?: Partial 59 | }) { 60 | return ( 61 | 62 | {content} 63 | 64 | ) 65 | }, 66 | function propsAreEqual(prevProps, nextProps) { 67 | return prevProps.content === nextProps.content 68 | } 69 | ) 70 | 71 | MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock" 72 | 73 | function MarkdownComponent({ children, id, className, components = INITIAL_COMPONENTS }: MarkdownProps) { 74 | const generatedId = useId() 75 | const blockId = id ?? generatedId 76 | const blocks = useMemo(() => parseMarkdownIntoBlocks(children), [children]) 77 | 78 | return ( 79 |
80 | {blocks.map((block, index) => ( 81 | 82 | ))} 83 |
84 | ) 85 | } 86 | 87 | const Markdown = memo(MarkdownComponent) 88 | Markdown.displayName = "Markdown" 89 | 90 | export { Markdown } 91 | -------------------------------------------------------------------------------- /components/message.tsx: -------------------------------------------------------------------------------- 1 | import { cva } from "class-variance-authority" 2 | import { Accessibility, Terminal, User } from "lucide-react" 3 | import React from "react" 4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 5 | import { ChatMessage } from "@/lib/chat/types" 6 | import { cn } from "@/lib/utils" 7 | 8 | const messageVariants = cva("group/message", { 9 | variants: { 10 | variant: { 11 | user: "bg-primary-foreground", 12 | assistant: "border-none pb-2 sm:pb-8", 13 | error: "border-none pb-2 sm:pb-8", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "user", 18 | }, 19 | }) 20 | 21 | type MessageProps = { 22 | message: ChatMessage 23 | } 24 | 25 | export function Message({ message }: MessageProps) { 26 | const variant = message.role 27 | const capitalizedRole = message.role.charAt(0).toUpperCase() + message.role.slice(1) 28 | 29 | return ( 30 | 31 | {message.role === "assistant" && } 32 | {message.role === "user" && } 33 | {message.role === "error" && } 34 | {capitalizedRole} 35 | {message.display} 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /components/prompt-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { LoaderIcon, SendIcon } from "lucide-react" 4 | import { useEffect, useRef, useState } from "react" 5 | import Textarea from "react-textarea-autosize" 6 | import { Button } from "@/components/ui/button" 7 | import { useEnterSubmit } from "@/hooks/use-enter-submit" 8 | import { AnimatedState } from "./ui/animate-state" 9 | import { useVibration } from "@/hooks/use-vibrate" 10 | 11 | type PromptFormProps = { 12 | addMessage: (input: string) => Promise 13 | isLoading: boolean 14 | isError?: boolean 15 | } 16 | 17 | export function PromptForm({ addMessage, isLoading, isError }: PromptFormProps) { 18 | const vibrate = useVibration() 19 | const { formRef, onKeyDown } = useEnterSubmit() 20 | const [input, setInput] = useState("") 21 | const inputRef = useRef(null) 22 | 23 | useEffect(() => { 24 | // Focus the input when the component mounts 25 | if (inputRef.current) { 26 | inputRef.current.focus() 27 | } 28 | }, []) 29 | 30 | async function handleSubmit(e: React.FormEvent) { 31 | e.preventDefault() 32 | 33 | if (isError) return 34 | 35 | setInput("") 36 | vibrate() 37 | await addMessage(input) 38 | } 39 | 40 | return ( 41 |
46 |