├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── README.md ├── docs └── architecture.png ├── package.json ├── packages ├── app │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── components │ │ ├── Header.tsx │ │ ├── NavigationMenu.tsx │ │ └── Toast.tsx │ ├── lib │ │ └── utils.ts │ ├── next.config.mjs │ ├── package.json │ ├── pages │ │ ├── 404.tsx │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── chat │ │ │ └── index.tsx │ │ ├── index.tsx │ │ └── notes │ │ │ ├── [id] │ │ │ └── index.tsx │ │ │ └── index.tsx │ ├── postcss.config.mjs │ ├── public │ │ ├── favicon.ico │ │ ├── icon-bedrock.png │ │ ├── icon-openai.svg │ │ ├── logo.png │ │ ├── next.svg │ │ └── vercel.svg │ ├── services │ │ ├── auth.ts │ │ ├── chat-api.ts │ │ └── notes-api.ts │ ├── sst-env.d.ts │ ├── styles │ │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json ├── core │ ├── package.json │ ├── src │ │ ├── adapter │ │ │ ├── bedrock │ │ │ │ └── bedrock.adapter.ts │ │ │ ├── database │ │ │ │ ├── dynamodb.adapter.ts │ │ │ │ └── model │ │ │ │ │ ├── chats.ts │ │ │ │ │ └── notes.ts │ │ │ └── openai │ │ │ │ └── openai.adapter.ts │ │ └── utils │ │ │ ├── core.ts │ │ │ ├── exception.ts │ │ │ └── middlewares.ts │ ├── sst-env.d.ts │ └── tsconfig.json └── functions │ ├── package.json │ ├── src │ ├── bedrock-api.ts │ ├── messages-api.ts │ ├── notes-api.ts │ └── openai-api.ts │ ├── sst-env.d.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── sst.config.ts ├── stacks ├── backend.ts ├── frontend.ts └── index.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # sst 5 | .sst 6 | .build 7 | 8 | # opennext 9 | .open-next 10 | 11 | # misc 12 | .DS_Store 13 | 14 | # local env files 15 | .env*.local 16 | .env 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "printWidth": 120 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug SST Start", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", 9 | "runtimeArgs": ["start", "--increase-timeout"], 10 | "console": "integratedTerminal", 11 | "skipFiles": ["/**"], 12 | "env": {} 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.sst": true 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "[javascript]": { 8 | "editor.formatOnSave": true 9 | }, 10 | "prettier.configPath": "./.prettierrc" 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serverless Chat Application with Amazon Bedrock and OpenAI 2 | 3 | Repository for the accompanying [blog post]([url](https://blog.awsfundamentals.com/amazon-bedrock-the-openai-api-and-sst)) and [newsletter](https://newsletter.awsfundamentals.com). 4 | 5 | ![image](https://github.com/awsfundamentals-hq/bedrock-openai-experiments-chat/assets/19362086/6d73a989-93e0-486b-8366-ea1aae0cc552) 6 | 7 | 8 | This project is a serverless chat application that leverages Amazon Bedrock and the OpenAI API to enhance chat functionalities with advanced AI-driven contextual understanding. Built using Serverless Stack (SST), NextJS, AWS Lambda, and DynamoDB, it offers a robust platform for real-time messaging enriched with AI capabilities. 9 | 10 | ## Architecture 11 | 12 | Below is the architecture diagram of the application, illustrating how different components interact within the AWS environment: 13 | 14 | ![Architecture Diagram](docs/architecture.png) 15 | 16 | ## Features 17 | 18 | - Real-time chat messaging. 19 | - Contextual note integration for smarter responses. 20 | - Use of Amazon Bedrock and OpenAI for natural language understanding. 21 | - Fully serverless backend with AWS Lambda and DynamoDB. 22 | 23 | ## Prerequisites 24 | 25 | - AWS CLI installed and configured with AWS account credentials. 26 | - Access to Amazon Bedrock and OpenAI APIs. 27 | - Node.js and NPM installed. 28 | 29 | ## Providing your OpenAI API key to SST 30 | 31 | To provide your OpenAI API key to SST, use the following command: 32 | 33 | ```bash 34 | npx sst secrets set OPENAI_API_KEY sk-Yj...BcZ 35 | ``` 36 | 37 | ## Deploying with SST 38 | 39 | To deploy the application, ensure you are in the project's root directory and then use the SST commands: 40 | 41 | ```bash 42 | npx sst deploy 43 | ``` 44 | 45 | ## Running Locally 46 | 47 | To run the application locally, use the following command: 48 | 49 | ```bash 50 | npx sst dev 51 | ``` 52 | 53 | Start the frontend by navigating to the `packages/app` directory and running: 54 | 55 | ```bash 56 | npm run dev 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awsfundamentals-hq/bedrock-openai-experiments-chat/dbc262da1992a65b44af3de09f56350114bde130/docs/architecture.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bedrock-openai-experiments-chat", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "sst dev", 8 | "build": "sst build", 9 | "deploy": "sst deploy", 10 | "remove": "sst remove", 11 | "console": "sst console", 12 | "typecheck": "tsc --noEmit" 13 | }, 14 | "devDependencies": { 15 | "@tsconfig/node18": "^18.2.4", 16 | "@types/node": "^20.12.7", 17 | "@types/uuid": "^9.0.8", 18 | "aws-cdk-lib": "2.132.1", 19 | "constructs": "10.3.0", 20 | "prettier": "^3.2.5", 21 | "sst": "^2.41.4", 22 | "typescript": "^5.4.5", 23 | "uuid": "^9.0.1" 24 | }, 25 | "workspaces": [ 26 | "packages/*" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /packages/app/.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 | -------------------------------------------------------------------------------- /packages/app/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 20 | 21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 22 | 23 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 24 | 25 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 26 | 27 | ## Learn More 28 | 29 | To learn more about Next.js, take a look at the following resources: 30 | 31 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 32 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 33 | 34 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 35 | 36 | ## Deploy on Vercel 37 | 38 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 39 | 40 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 41 | -------------------------------------------------------------------------------- /packages/app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | NavigationMenu, 3 | NavigationMenuItem, 4 | NavigationMenuLink, 5 | NavigationMenuList, 6 | } from '@radix-ui/react-navigation-menu'; 7 | import Image from 'next/image'; 8 | import React from 'react'; 9 | 10 | const Header: React.FC = () => { 11 | return ( 12 |
13 |
14 |
15 |
16 | 22 | Logo 29 | 30 | 31 | 32 | 33 | 34 | 38 | 📝 Notes 39 | 40 | 41 | 42 | 46 | 💬 Chat 47 | 48 | 49 | 50 | 51 |
52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export default Header; 59 | -------------------------------------------------------------------------------- /packages/app/components/NavigationMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" 3 | import { cva } from "class-variance-authority" 4 | import { ChevronDown } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const NavigationMenu = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 20 | {children} 21 | 22 | 23 | )) 24 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName 25 | 26 | const NavigationMenuList = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, ...props }, ref) => ( 30 | 38 | )) 39 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName 40 | 41 | const NavigationMenuItem = NavigationMenuPrimitive.Item 42 | 43 | const navigationMenuTriggerStyle = cva( 44 | "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" 45 | ) 46 | 47 | const NavigationMenuTrigger = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, children, ...props }, ref) => ( 51 | 56 | {children}{" "} 57 | 62 | )) 63 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName 64 | 65 | const NavigationMenuContent = React.forwardRef< 66 | React.ElementRef, 67 | React.ComponentPropsWithoutRef 68 | >(({ className, ...props }, ref) => ( 69 | 77 | )) 78 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName 79 | 80 | const NavigationMenuLink = NavigationMenuPrimitive.Link 81 | 82 | const NavigationMenuViewport = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 |
87 | 95 |
96 | )) 97 | NavigationMenuViewport.displayName = 98 | NavigationMenuPrimitive.Viewport.displayName 99 | 100 | const NavigationMenuIndicator = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 112 |
113 | 114 | )) 115 | NavigationMenuIndicator.displayName = 116 | NavigationMenuPrimitive.Indicator.displayName 117 | 118 | export { 119 | navigationMenuTriggerStyle, 120 | NavigationMenu, 121 | NavigationMenuList, 122 | NavigationMenuItem, 123 | NavigationMenuContent, 124 | NavigationMenuTrigger, 125 | NavigationMenuLink, 126 | NavigationMenuIndicator, 127 | NavigationMenuViewport, 128 | } 129 | -------------------------------------------------------------------------------- /packages/app/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | interface ToastProps { 4 | message: string; 5 | isVisible: boolean; 6 | onClose: () => void; 7 | } 8 | 9 | const Toast: React.FC = ({ message, isVisible, onClose }) => { 10 | useEffect(() => { 11 | if (isVisible) { 12 | const timer = setTimeout(() => { 13 | onClose(); 14 | }, 3000); 15 | return () => clearTimeout(timer); 16 | } 17 | }, [isVisible, onClose]); 18 | 19 | if (!isVisible) return null; 20 | 21 | return ( 22 |
23 | {message} 24 | 27 |
28 | ); 29 | }; 30 | 31 | export default Toast; 32 | -------------------------------------------------------------------------------- /packages/app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function debounce void>( 9 | func: F, 10 | wait: number, 11 | ) { 12 | let timeoutId: ReturnType | null = null; 13 | 14 | const debouncedFunction = (...args: any[]) => { 15 | if (timeoutId !== null) { 16 | clearTimeout(timeoutId); 17 | } 18 | timeoutId = setTimeout(() => func(...args), wait); 19 | }; 20 | 21 | debouncedFunction.cancel = () => { 22 | if (timeoutId !== null) { 23 | clearTimeout(timeoutId); 24 | } 25 | }; 26 | 27 | return debouncedFunction; 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "sst bind next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-navigation-menu": "^1.1.4", 13 | "class-variance-authority": "^0.7.0", 14 | "clsx": "^2.0.0", 15 | "lucide-react": "^0.274.0", 16 | "luxon": "^3.4.4", 17 | "next": "14.2.1", 18 | "react": "^18", 19 | "react-dom": "^18", 20 | "react-query": "^3.39.3", 21 | "tailwind-merge": "^1.14.0" 22 | }, 23 | "devDependencies": { 24 | "@types/luxon": "^3.4.2", 25 | "@types/node": "^20", 26 | "@types/react": "^18", 27 | "@types/react-dom": "^18", 28 | "eslint": "^8", 29 | "eslint-config-next": "14.2.1", 30 | "postcss": "^8", 31 | "tailwindcss": "^3.4.1", 32 | "typescript": "^5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/app/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | 4 | export default function Custom404() { 5 | const router = useRouter(); 6 | 7 | useEffect(() => { 8 | router.replace('/notes'); 9 | }); 10 | 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /packages/app/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@/components/Header'; // If you want the Header to be global 2 | import '@/styles/globals.css'; 3 | import { AppProps } from 'next/app'; 4 | import { QueryClient, QueryClientProvider } from 'react-query'; 5 | 6 | const queryClient = new QueryClient(); 7 | 8 | function MyApp({ Component, pageProps }: AppProps) { 9 | return ( 10 | 11 |
{/* If you want the Header to be part of every page */} 12 | 13 | 14 | ); 15 | } 16 | 17 | export default MyApp; 18 | -------------------------------------------------------------------------------- /packages/app/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/pages/chat/index.tsx: -------------------------------------------------------------------------------- 1 | // components/ChatComponent.js 2 | import { ChatMessage, clearMessages, getMessages, listModels, submitPrompt } from '@/services/chat-api'; 3 | import { DateTime } from 'luxon'; 4 | import { useEffect, useState } from 'react'; 5 | import { useMutation } from 'react-query'; 6 | 7 | function ChatComponent() { 8 | const [adapter, setAdapter] = useState<'openai' | 'bedrock' | undefined>(undefined); 9 | const [isClearPending, setIsClearPending] = useState(false); 10 | const [messages, setMessages] = useState([]); 11 | const [input, setInput] = useState(''); 12 | const [selectedModel, setSelectedModel] = useState(); 13 | const [isLoading, setIsLoading] = useState(true); 14 | const [models, setModels] = useState<{ id: string }[]>([]); 15 | 16 | useEffect(() => { 17 | if (!adapter) return; // Don't load messages if no adapter is selected 18 | if (adapter === 'openai') setSelectedModel('gpt-4-turbo'); 19 | if (adapter === 'bedrock') setSelectedModel('amazon.titan-text-express-v1'); 20 | const loadModels = async () => { 21 | const m = await listModels(adapter); 22 | setModels(m); 23 | setIsLoading(false); 24 | }; 25 | const loadMessages = async () => { 26 | const existingMessages = await getMessages(); 27 | // sort by timestamp ascending 28 | existingMessages.sort((a, b) => a.timestamp! - b.timestamp!); 29 | setMessages(existingMessages); 30 | }; 31 | loadModels(); 32 | loadMessages(); 33 | }, [adapter]); 34 | 35 | const { 36 | mutate, 37 | isLoading: isSubmitting, 38 | isError: submitError, 39 | } = useMutation(submitPrompt, { 40 | onSuccess: ({ content }) => { 41 | // Update messages by removing isLoading flag and adding response 42 | setMessages((currentMessages) => 43 | currentMessages 44 | .map((msg) => (msg.isLoading ? { ...msg, isLoading: false } : msg)) 45 | .concat([{ content, role: 'assistant', timestamp: Date.now() }]), 46 | ); 47 | }, 48 | onError: (error) => { 49 | console.error('Message sending failed:', error); 50 | // Remove loading indicator if error occurs 51 | setMessages((currentMessages) => 52 | currentMessages.map((msg) => (msg.isLoading ? { ...msg, isLoading: false } : msg)), 53 | ); 54 | }, 55 | }); 56 | 57 | const sendMessage = (e: any) => { 58 | e.preventDefault(); 59 | if (input.trim()) { 60 | setMessages((prev) => [ 61 | ...prev, 62 | { 63 | id: Math.random().toString(36).substring(2, 9), 64 | timestamp: Date.now(), 65 | content: input, 66 | model: selectedModel, 67 | role: 'user', 68 | isLoading: true, 69 | }, 70 | ]); 71 | mutate({ adapter: adapter!, content: input, model: selectedModel! }); 72 | setInput(''); 73 | } 74 | }; 75 | 76 | const handleClearChat = async () => { 77 | setIsClearPending(true); 78 | await clearMessages(); // Call to service method to clear messages on the backend 79 | setMessages([]); // Clear messages on the frontend 80 | setIsClearPending(false); 81 | }; 82 | 83 | const handleClearAdapter = () => { 84 | setAdapter(undefined); 85 | setModels([]); 86 | }; 87 | 88 | if (!adapter) { 89 | return ( 90 |
91 |
92 |

Select an Adapter

93 | 99 | 105 |
106 |
107 | ); 108 | } 109 | 110 | if (isLoading) 111 | return ( 112 |
113 |
Loading...
114 |
115 | ); 116 | 117 | return ( 118 |
119 |
120 | 133 | 140 | 146 |
147 |
148 | {messages.map((message, index) => ( 149 |
153 | {message.content} 154 | {message.isLoading && (sending...)} 155 | {message.role === 'user' ? ' (You)' : ' (AI)'} 156 | {message.timestamp && ( 157 |
{DateTime.fromMillis(message.timestamp).toRelative()}
158 | )} 159 |
160 | ))} 161 |
162 |
163 | setInput(e.target.value)} 167 | className="flex-grow p-2 border rounded focus:outline-none focus:border-blue-500" 168 | placeholder="Type your message..." 169 | disabled={isSubmitting} 170 | /> 171 | 178 |
179 | {submitError &&
Failed to send message. Please try again.
} 180 |
181 | ); 182 | } 183 | 184 | export default ChatComponent; 185 | -------------------------------------------------------------------------------- /packages/app/pages/index.tsx: -------------------------------------------------------------------------------- 1 | // Home.tsx 2 | import Header from '@/components/Header'; 3 | import router from 'next/router'; 4 | import { useEffect } from 'react'; 5 | import { QueryClient, QueryClientProvider } from 'react-query'; 6 | 7 | const queryClient = new QueryClient(); 8 | 9 | export default function Home() { 10 | // by default, redirect to /notes 11 | useEffect(() => { 12 | router.replace('/notes'); 13 | }); 14 | 15 | return ( 16 | 17 |
18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /packages/app/pages/notes/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import React, { useState, useEffect } from 'react'; 3 | import { useMutation, useQuery } from 'react-query'; 4 | import { 5 | createNote, 6 | fetchNoteById, 7 | updateNote, 8 | } from '../../../services/notes-api'; 9 | 10 | const Note: React.FC = () => { 11 | const router = useRouter(); 12 | const { id } = router.query; // Access `id` from the URL 13 | const [desc, setDesc] = useState(''); 14 | const [txt, setTxt] = useState(''); 15 | 16 | useEffect(() => { 17 | // Reset form when id changes to 'new' 18 | if (id === 'new') { 19 | setDesc(''); 20 | setTxt(''); 21 | } 22 | }, [id]); 23 | 24 | // Fetch note details if `id` is available and not 'new' 25 | const { isLoading: isFetching } = useQuery( 26 | ['note', id], 27 | () => fetchNoteById(id as string), 28 | { 29 | enabled: !!id && id !== 'new', // Only run the query if `id` is not null and not 'new' 30 | onSuccess: (data) => { 31 | setDesc(data.description); 32 | setTxt(data.text); 33 | }, 34 | }, 35 | ); 36 | 37 | const mutation = useMutation(id && id !== 'new' ? updateNote : createNote, { 38 | onSuccess: () => { 39 | // Ensure navigation occurs after successful save 40 | router.push('/notes'); 41 | }, 42 | }); 43 | 44 | const handleSave = () => { 45 | const noteData = { 46 | id: (id === 'new' ? undefined : id) as string, 47 | description: desc, 48 | text: txt, 49 | }; 50 | mutation.mutate(noteData); 51 | }; 52 | 53 | if (isFetching) return
Loading...
; 54 | 55 | return ( 56 |
57 | setDesc(e.target.value)} 61 | placeholder="Description" 62 | /> 63 |