Repository: vercel/next-server-components Branch: main Commit: 91d5cbde29e3 Files: 30 Total size: 47.6 KB Directory structure: gitextract_nu1edy77/ ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── actions.ts │ ├── kv-client.ts │ ├── layout.tsx │ ├── note/ │ │ ├── [id]/ │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ └── edit/ │ │ ├── [id]/ │ │ │ └── page.tsx │ │ ├── loading.tsx │ │ └── page.tsx │ ├── page.tsx │ └── style.css ├── components/ │ ├── auth-button.tsx │ ├── note-editor.tsx │ ├── note-list-skeleton.tsx │ ├── note-list.tsx │ ├── note-preview.tsx │ ├── note-ui.tsx │ ├── search.tsx │ ├── sidebar-note.tsx │ └── sidebar.tsx ├── libs/ │ └── session.ts ├── middleware/ │ ├── api.ts │ ├── auth.ts │ ├── edit.ts │ └── logout.ts ├── middleware.ts ├── package.json └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem .vscode # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Vercel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Next.js App Router + Server Components Notes Demo > Try the demo live here: [**next-rsc-notes.vercel.app**](https://next-rsc-notes.vercel.app). This demo was originally [built by the React team](https://github.com/reactjs/server-components-demo). This version has been forked and modified for use with the Next.js App Router. ## Introduction This is a demo app of a notes application, which shows the Next.js App Router with support for React Server Components. [Learn more](https://nextjs.org/docs/getting-started/react-essentials). ### Environment Variables These environment variables are required to start this application (you can create a `.env` file for Next.js to use): ```bash KV_URL='redis://:@:' # vercel.com/kv SESSION_KEY='your session key' OAUTH_CLIENT_KEY='github oauth app id' OAUTH_CLIENT_SECRET='github oauth app secret' ``` ### Running Locally 1. `pnpm install` 2. `pnpm dev` Go to `localhost:3000`. ### Deploy You can quickly deploy the demo to Vercel by clicking this link: [![Deploy with Vercel](https://vercel.com/button)]() ## License This demo is MIT licensed. Originally [built by the React team](https://github.com/reactjs/server-components-demo) ================================================ FILE: app/actions.ts ================================================ 'use server' import { kv } from './kv-client' import { getUser, userCookieKey } from 'libs/session' import { cookies } from 'next/headers' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' export async function saveNote( noteId: string | null, title: string, body: string ) { const cookieStore = await cookies() const userCookie = cookieStore.get(userCookieKey) const user = getUser(userCookie?.value) if (!noteId) { noteId = Date.now().toString() } const payload = { id: noteId, title: title.slice(0, 255), updated_at: Date.now(), body: body.slice(0, 2048), created_by: user } await kv.hset('notes', { [noteId]: JSON.stringify(payload) }) revalidatePath('/') redirect(`/note/${noteId}`) } export async function deleteNote(noteId: string) { revalidatePath('/') redirect('/') } ================================================ FILE: app/kv-client.ts ================================================ import { kv as vercelKv } from '@vercel/kv' const hasKvConfig = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN export const kv = hasKvConfig ? vercelKv : { hgetall: async () => ({}), hset: async () => {}, hget: async () => null } ================================================ FILE: app/layout.tsx ================================================ import './style.css' import React from 'react' import { kv } from './kv-client' import Sidebar from 'components/sidebar' import AuthButton from 'components/auth-button' export const metadata = { title: 'Next.js App Router + React Server Components Demo', description: 'Demo of React Server Components in Next.js. Hosted on Vercel.', openGraph: { title: 'Next.js App Router + React Server Components Demo', description: 'Demo of React Server Components in Next.js. Hosted on Vercel.', images: ['https://next-rsc-notes.vercel.app/og.png'] }, robots: { index: true, follow: true }, metadataBase: new URL('https://next-rsc-notes.vercel.app/') } type Note = { id: string created_by: string title: string body: string updated_at: number } export default async function RootLayout({ children }: { children: React.ReactNode }) { const notes = await kv.hgetall('notes') let notesArray: Note[] = notes ? (Object.values(notes) as Note[]).sort( (a, b) => Number(b.id) - Number(a.id) ) : [] return (
Add
{children}
) } ================================================ FILE: app/note/[id]/loading.tsx ================================================ export default function NoteSkeleton() { return (
) } ================================================ FILE: app/note/[id]/page.tsx ================================================ import { kv } from '../../kv-client' import NoteUI from 'components/note-ui' export const metadata = { robots: { index: false } } export default async function Page(props: { params: Promise<{ id: string }> }) { const params = await props.params; const note = await kv.hget('notes', params.id) if (note === null) { return (
Click a note on the left to view something! 🥺
) } return } ================================================ FILE: app/note/edit/[id]/page.tsx ================================================ import { kv } from '../../../kv-client' import { cookies } from 'next/headers' import { getUser, userCookieKey } from 'libs/session' import NoteUI from 'components/note-ui' export const metadata = { robots: { index: false } } type Note = { id: string created_by: string } export default async function EditPage(props: { params: Promise<{ id: string }> }) { const params = await props.params; const cookieStore = await cookies() const userCookie = cookieStore.get(userCookieKey) const user = getUser(userCookie?.value) const note = await kv.hget('notes', params.id) const isCreator = note?.created_by === user || true if (note === null) { return (
Click a note on the left to view something! 🥺
) } return } ================================================ FILE: app/note/edit/loading.tsx ================================================ export default function EditSkeleton() { return (
) } ================================================ FILE: app/note/edit/page.tsx ================================================ import NoteUI from 'components/note-ui' export const metadata = { robots: { index: false } } export default async function EditPage() { const defaultNote = { title: 'Untitled', body: '' } return } ================================================ FILE: app/page.tsx ================================================ export default async function Page() { return (
Click a note on the left to view something! 🥺
) } ================================================ FILE: app/style.css ================================================ /* -------------------------------- CSSRESET --------------------------------*/ /* CSS Reset adapted from https://dev.to/hankchizljaw/a-modern-css-reset-6p3 */ /* Box sizing rules */ *, *::before, *::after { box-sizing: border-box; } /* Remove default padding */ ul[class], ol[class] { padding: 0; } /* Remove default margin */ body, h1, h2, h3, h4, p, ul[class], ol[class], li, figure, figcaption, blockquote, dl, dd { margin: 0; } /* Set core body defaults */ body { scroll-behavior: smooth; text-rendering: optimizeSpeed; line-height: 1.5; } /* Remove list styles on ul, ol elements with a class attribute */ ul[class], ol[class] { list-style: none; } /* A elements that don't have a class get default styles */ a:not([class]) { text-decoration-skip-ink: auto; } /* Make images easier to work with */ img { max-width: 100%; display: block; } /* Natural flow and rhythm in articles by default */ article > * + * { margin-block-start: 1em; } /* Inherit fonts for inputs and buttons */ input, button, textarea, select { font: inherit; -webkit-tap-highlight-color: transparent; } /* Remove all animations and transitions for people that prefer not to see them */ @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } /* -------------------------------- /CSSRESET --------------------------------*/ :root { /* Colors */ --main-border-color: #ddd; --primary-border: #037dba; --gray-20: #404346; --gray-60: #8a8d91; --gray-70: #bcc0c4; --gray-80: #c9ccd1; --gray-90: #e4e6eb; --gray-95: #f0f2f5; --gray-100: #f5f7fa; --primary-blue: #037dba; --secondary-blue: #0396df; --tertiary-blue: #c6efff; --flash-blue: #4cf7ff; --outline-blue: rgba(4, 164, 244, 0.6); --navy-blue: #035e8c; --red-25: #bd0d2a; --secondary-text: #65676b; --white: #fff; --yellow: #fffae1; --outline-box-shadow: 0 0 0 2px var(--outline-blue); --outline-box-shadow-contrast: 0 0 0 2px var(--navy-blue); /* Fonts */ --sans-serif: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, Helvetica, sans-serif; --monospace: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; } html { font-size: 100%; height: 100%; } body { font-family: var(--sans-serif); background: var(--gray-100); font-weight: 400; line-height: 1.75; height: 100%; } h1, h2, h3, h4, h5 { margin: 0; font-weight: 700; line-height: 1.3; } h1 { font-size: 3.052rem; } h2 { font-size: 2.441rem; } h3 { font-size: 1.953rem; } h4 { font-size: 1.563rem; } h5 { font-size: 1.25rem; } small, .text_small { font-size: 0.8rem; } pre, code { font-family: var(--monospace); border-radius: 6px; } pre { background: var(--gray-95); padding: 12px; line-height: 1.5; overflow: auto; } code { background: var(--yellow); padding: 0 3px; font-size: 0.94rem; word-break: break-word; } pre code { background: none; } a { color: var(--primary-blue); } .text-with-markdown h1, .text-with-markdown h2, .text-with-markdown h3, .text-with-markdown h4, .text-with-markdown h5 { margin-block: 2rem 0.7rem; margin-inline: 0; } .text-with-markdown blockquote { font-style: italic; color: var(--gray-20); border-left: 3px solid var(--gray-80); padding-left: 10px; } hr { border: 0; height: 0; border-top: 1px solid rgba(0, 0, 0, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.3); } /* ---------------------------------------------------------------------------*/ #__next { height: 100%; } .container { height: 100%; display: flex; flex-direction: column; } .main { flex: 1; position: relative; display: flex; width: 100%; overflow: hidden; } .col { height: 100%; } .col:last-child { flex-grow: 1; } .logo { height: 20px; width: 22px; margin-inline-end: 10px; } .avatar { width: 20px; height: 20px; display: inline-block; border-radius: 100%; border: 1px solid #ccc; vertical-align: -5px; margin-left: 5px; } button .avatar { margin-right: -5px; } .edit-button { border-radius: 100px; letter-spacing: 0.12em; text-transform: uppercase; padding: 4px 14px; cursor: pointer; font-weight: 700; font-size: 14px; outline-style: none; white-space: nowrap; flex: 0 0 auto; } .edit-button--solid { background: var(--primary-blue); color: var(--white); border: none; margin: 0 0 0 10px; transition: all 0.2s ease-in-out; } @media (hover: hover) { .edit-button--solid:hover { background: var(--secondary-blue); } } .edit-button--solid:focus { box-shadow: var(--outline-box-shadow-contrast); } .edit-button--outline { background: var(--white); color: var(--primary-blue); border: 1px solid var(--primary-blue); margin: 0 0 0 12px; transition: all 0.1s ease-in-out; } .edit-button--outline:disabled { opacity: 0.5; } @media (hover: hover) { .edit-button--outline:hover:not([disabled]) { background: var(--primary-blue); color: var(--white); } } .edit-button--outline:focus { box-shadow: var(--outline-box-shadow); } ul.notes-list { padding: 16px 0; } .notes-list > li { padding: 0 16px; } .notes-empty { padding: 16px; } .sidebar { background: var(--white); box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.1), 0px 2px 2px rgba(0, 0, 0, 0.1); overflow-y: scroll; z-index: 1000; flex-shrink: 0; max-width: 350px; min-width: 250px; width: 30%; height: auto; } .sidebar-toggle { display: none; position: fixed; z-index: 9999; top: 40px; left: 10px; margin: 0; width: 30px; height: 30px; background: url(/x.svg) #e2e2e2; border-radius: 6px; border: none; appearance: none; background-size: 24px; background-position: center; background-repeat: no-repeat; } .sidebar-header { letter-spacing: 0.12em; text-transform: uppercase; padding: 16px; display: flex; align-items: center; } .sidebar-menu { padding: 0 16px; display: flex; justify-content: space-between; } .sidebar-menu > .search { position: relative; flex-grow: 1; } .sidebar-note-list-item { position: relative; margin-bottom: 12px; padding: 16px; width: 100%; display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; max-height: 100px; transition: max-height 250ms ease-out; transform: scale(1); } .sidebar-note-list-item.note-expanded { max-height: 300px; transition: max-height 0.5s ease; } .sidebar-note-list-item.flash { animation-name: flash; animation-duration: 0.6s; } .banner { z-index: 2000; width: 100%; background: #1f1f1f; color: #e6e6e6; text-align: center; padding: 3px; font-size: 14px; font-family: -apple-system, arial, sans-serif; white-space: nowrap; overflow: hidden; letter-spacing: -0.02em; flex: 0 0 auto; } .banner:before { content: 'The Next.js App Router uses React Server Components by default. '; } .banner a { color: #3aa3ff; } .screen-center { height: 100vh; width: 100vw; display: flex; align-items: center; justify-content: center; } @media screen and (max-width: 640px) { .banner:before { content: 'Next.js App Router using React Server Components. '; } .banner { font-size: 13px; } .sidebar-toggle { display: block; } .sidebar { position: absolute; transition: all 0.4s ease; height: 100%; } .sidebar-toggle:checked { background: url(/menu.svg) #e2e2e2; background-size: 24px; background-position: center; background-repeat: no-repeat; } .sidebar-toggle:checked + .sidebar { transform: translateX(-120%); } .sidebar-toggle:not(:checked) ~ .note-viewer { opacity: 0.2; pointer-events: none; } .sidebar-header { padding-top: 14px; justify-content: flex-end; } .note-editor-done, .note-editor-delete, .edit-button { padding: 3px 10px !important; margin: 0 0 0 4px !important; letter-spacing: 0.03em !important; font-size: 13px !important; } .label { padding: 4px 10px !important; font-size: 13px !important; } input, textarea { font-size: 16px !important; } } .sidebar-note-open { position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; z-index: 0; border: none; border-radius: 6px; text-align: start; background: var(--gray-95); cursor: pointer; outline-style: none; color: transparent; font-size: 0px; appearance: none; } .sidebar-note-open:focus { box-shadow: var(--outline-box-shadow); } @media (hover: hover) { .sidebar-note-open:hover { background: var(--gray-90); } } .sidebar-note-header { z-index: 1; max-width: 85%; pointer-events: none; } .sidebar-note-header > strong { display: block; font-size: 1.25rem; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sidebar-note-toggle-expand { z-index: 2; border-radius: 50%; height: 24px; width: 24px; padding: 0 6px; border: 1px solid var(--gray-60); cursor: pointer; flex-shrink: 0; visibility: hidden; opacity: 0; cursor: default; transition: visibility 0s linear 20ms, opacity 300ms; outline-style: none; } .sidebar-note-toggle-expand:focus { box-shadow: var(--outline-box-shadow); } .sidebar-note-open:focus + .sidebar-note-toggle-expand, .sidebar-note-toggle-expand:focus { visibility: visible; opacity: 1; transition: visibility 0s linear 0s, opacity 300ms; } @media (hover: hover) { .sidebar-note-open:hover + .sidebar-note-toggle-expand, .sidebar-note-toggle-expand:hover { visibility: visible; opacity: 1; transition: visibility 0s linear 0s, opacity 300ms; } } .sidebar-note-toggle-expand img { width: 10px; height: 10px; } .sidebar-note-excerpt { pointer-events: none; z-index: 2; flex: 1 1 250px; color: var(--secondary-text); position: relative; animation: slideIn 100ms; } .search input { padding: 0 16px; border-radius: 100px; border: 1px solid var(--gray-90); width: 100%; height: 100%; outline-style: none; appearance: none; } .search input:focus { box-shadow: var(--outline-box-shadow); } .search .spinner { position: absolute; right: 10px; top: 6px; } .note-viewer { display: flex; align-items: center; justify-content: center; height: auto; max-width: 100%; transition: opacity 0.3s ease; } .note { background: var(--white); box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1), 0px 0px 2px rgba(0, 0, 0, 0.1); border-radius: 8px; width: calc(100% - 32px); height: calc(100% - 32px); padding: 16px; overflow-y: auto; } .note--empty-state { margin: 20px; text-align: center; } .note-text--empty-state { font-size: 1.5rem; } .note-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap-reverse; } .note-menu { display: flex; justify-content: space-between; align-items: center; flex-grow: 1; margin-bottom: 10px; overflow: hidden; width: 100%; height: 36px; } .note-title { line-height: 1.3; flex-grow: 1; overflow-wrap: break-word; word-break: break-word; } .note-updated-at { color: var(--secondary-text); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .note-preview { margin: 50px 0; } .note-editor { display: flex; padding: 16px; background: var(--white); box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1), 0px 0px 2px rgba(0, 0, 0, 0.1); border-radius: 8px; width: calc(100% - 32px); height: calc(100% - 32px); overflow-y: auto; } .note-editor .label { margin-bottom: 10px; } .note-editor-form { display: flex; flex-direction: column; width: 400px; max-width: 50%; flex-shrink: 0; position: sticky; top: 0; } .note-editor-form input, .note-editor-form textarea { background: none; border: 1px solid var(--gray-70); border-radius: 4px; font-family: var(--monospace); font-size: 0.8rem; padding: 12px; outline-style: none; appearance: none; } .note-editor-form input:focus, .note-editor-form textarea:focus { box-shadow: var(--outline-box-shadow); } .note-editor-form input { height: 44px; margin-bottom: 16px; } .note-editor-form textarea { height: 100%; max-width: 400px; } .note-editor-menu { display: flex; justify-content: flex-end; align-items: center; margin-bottom: 10px; float: right; } .note-editor-preview { margin-inline-start: 40px; width: 100%; } .note-editor-done, .note-editor-delete { display: flex; justify-content: space-between; align-items: center; border-radius: 100px; letter-spacing: 0.12em; text-transform: uppercase; padding: 4px 14px; cursor: pointer; font-weight: 700; font-size: 14px; margin: 0 0 0 10px; outline-style: none; transition: all 0.2s ease-in-out; } .note-editor-done:disabled, .note-editor-delete:disabled { opacity: 0.5; } .note-editor-done { border: none; background: var(--primary-blue); color: var(--white); } .note-editor-done:focus { box-shadow: var(--outline-box-shadow-contrast); } @media (hover: hover) { .note-editor-done:hover:not([disabled]) { background: var(--secondary-blue); } } .note-editor-delete { border: 1px solid var(--red-25); background: var(--white); color: var(--red-25); } .note-editor-delete:focus { box-shadow: var(--outline-box-shadow); } @media (hover: hover) { .note-editor-delete:hover:not([disabled]) { background: var(--red-25); color: var(--white); } /* Hack to color our svg */ .note-editor-delete:hover:not([disabled]) img { filter: grayscale(1) invert(1) brightness(2); } } .note-editor-done > img { width: 14px; } .note-editor-delete > img { width: 10px; } .note-editor-done > img, .note-editor-delete > img { margin-inline-end: 12px; } .note-editor-done[disabled], .note-editor-delete[disabled] { opacity: 0.5; } .label { display: inline-block; border-radius: 100px; text-transform: uppercase; font-weight: 700; font-size: 14px; padding: 5px 14px; } .label--preview { background: rgba(38, 183, 255, 0.15); color: var(--primary-blue); } .text-with-markdown p { margin-bottom: 16px; white-space: break-spaces; } .text-with-markdown img { width: 100%; } /* https://codepen.io/mandelid/pen/vwKoe */ .spinner { display: inline-block; transition: opacity linear 0.1s; width: 20px; height: 20px; border: 3px solid rgba(80, 80, 80, 0.5); border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite; opacity: 0; } .spinner--active { opacity: 1; } .skeleton::after { content: 'Loading...'; } .skeleton { height: 100%; background-color: #eee; background-image: linear-gradient(90deg, #eee, #f5f5f5, #eee); background-size: 200px 100%; background-repeat: no-repeat; border-radius: 4px; display: block; line-height: 1; width: 100%; animation: shimmer 1.2s ease-in-out infinite; color: transparent; } .skeleton:first-of-type { margin: 0; } .skeleton--button { border-radius: 100px; padding: 6px 20px; width: auto; } .v-stack + .v-stack { margin-block-start: 0.8em; } .offscreen { border: 0; clip: rect(0, 0, 0, 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; width: 1px; position: absolute; } .link--unstyled, .link--unstyled:active, .link--unstyled:visited, .link--unstyled:focus { text-decoration: none; color: unset; } @media screen and (max-width: 900px) { .note-editor { flex-direction: column-reverse; } .note-editor-preview { margin-inline-start: 0; overflow: auto; margin-bottom: 20px; flex: 2; } .note-editor-form { flex: 1; width: 100%; max-width: 100%; } .note-editor-form textarea { max-width: 100%; } } /* ---------------------------------------------------------------------------*/ @keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } } @keyframes shimmer { 0% { background-position: -200px 0; } 100% { background-position: calc(200px + 100%) 0; } } @keyframes slideIn { 0% { top: -10px; opacity: 0; } 100% { top: 0; opacity: 1; } } @keyframes flash { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.05); opacity: 0.9; } 100% { transform: scale(1); opacity: 1; } } ================================================ FILE: components/auth-button.tsx ================================================ import Link from 'next/link' import { cookies } from 'next/headers' import { getUser, userCookieKey } from 'libs/session' export default async function AuthButton({ children, noteId }: { children: React.ReactNode noteId: string | null }) { const cookieStore = await cookies() const userCookie = cookieStore.get(userCookieKey) const user = getUser(userCookie?.value) const isDraft = noteId == null if (user) { return ( // Use hard link ) } return ( ) } ================================================ FILE: components/note-editor.tsx ================================================ 'use client' import { useState } from 'react' import NotePreview from './note-preview' import { useFormStatus } from 'react-dom' import { deleteNote, saveNote } from '../app/actions' export default function NoteEditor({ noteId, initialTitle, initialBody }: { noteId: string | null initialTitle: string initialBody: string }) { const { pending } = useFormStatus() const [title, setTitle] = useState(initialTitle) const [body, setBody] = useState(initialBody) const isDraft = !noteId return (
{ setTitle(e.target.value) }} />