[
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n.vscode\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 Vercel\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Next.js App Router + Server Components Notes Demo\n\n> Try the demo live here: [**next-rsc-notes.vercel.app**](https://next-rsc-notes.vercel.app).\n\nThis 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.\n\n## Introduction\n\nThis 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).\n\n### Environment Variables\n\nThese environment variables are required to start this application (you can create a `.env` file for Next.js to use):\n\n```bash\nKV_URL='redis://:<password>@<url>:<port>' # vercel.com/kv\nSESSION_KEY='your session key'\nOAUTH_CLIENT_KEY='github oauth app id'\nOAUTH_CLIENT_SECRET='github oauth app secret'\n```\n\n### Running Locally\n\n1. `pnpm install`\n2. `pnpm dev`\n\nGo to `localhost:3000`.\n\n### Deploy\n\nYou can quickly deploy the demo to Vercel by clicking this link:\n\n[![Deploy with Vercel](https://vercel.com/button)](<https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fserver-components-notes-demo&env=REDIS_URL,SESSION_KEY,OAUTH_CLIENT_KEY,OAUTH_CLIENT_SECRET&project-name=next-rsc-notes&repo-name=next-rsc-notes&demo-title=React%20Server%20Components%20(Experimental%20Demo)&demo-description=Experimental%20demo%20of%20React%20Server%20Components%20with%20Next.js.%20&demo-url=https%3A%2F%2Fnext-rsc-notes.vercel.app&demo-image=https%3A%2F%2Fnext-rsc-notes.vercel.app%2Fog.png>)\n\n## License\n\nThis demo is MIT licensed. Originally [built by the React team](https://github.com/reactjs/server-components-demo)\n"
  },
  {
    "path": "app/actions.ts",
    "content": "'use server'\n\nimport { kv } from './kv-client'\nimport { getUser, userCookieKey } from 'libs/session'\nimport { cookies } from 'next/headers'\nimport { revalidatePath } from 'next/cache'\nimport { redirect } from 'next/navigation'\n\nexport async function saveNote(\n  noteId: string | null,\n  title: string,\n  body: string\n) {\n  const cookieStore = await cookies()\n  const userCookie = cookieStore.get(userCookieKey)\n  const user = getUser(userCookie?.value)\n\n  if (!noteId) {\n    noteId = Date.now().toString()\n  }\n\n  const payload = {\n    id: noteId,\n    title: title.slice(0, 255),\n    updated_at: Date.now(),\n    body: body.slice(0, 2048),\n    created_by: user\n  }\n\n  await kv.hset('notes', { [noteId]: JSON.stringify(payload) })\n\n  revalidatePath('/')\n  redirect(`/note/${noteId}`)\n}\n\nexport async function deleteNote(noteId: string) {\n  revalidatePath('/')\n  redirect('/')\n}\n"
  },
  {
    "path": "app/kv-client.ts",
    "content": "import { kv as vercelKv } from '@vercel/kv'\n\nconst hasKvConfig =\n  process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN\n\nexport const kv = hasKvConfig\n  ? vercelKv\n  : {\n      hgetall: async () => ({}),\n      hset: async () => {},\n      hget: async () => null\n    }\n"
  },
  {
    "path": "app/layout.tsx",
    "content": "import './style.css'\n\nimport React from 'react'\nimport { kv } from './kv-client'\nimport Sidebar from 'components/sidebar'\nimport AuthButton from 'components/auth-button'\n\nexport const metadata = {\n  title: 'Next.js App Router + React Server Components Demo',\n  description: 'Demo of React Server Components in Next.js. Hosted on Vercel.',\n  openGraph: {\n    title: 'Next.js App Router + React Server Components Demo',\n    description:\n      'Demo of React Server Components in Next.js. Hosted on Vercel.',\n    images: ['https://next-rsc-notes.vercel.app/og.png']\n  },\n  robots: {\n    index: true,\n    follow: true\n  },\n  metadataBase: new URL('https://next-rsc-notes.vercel.app/')\n}\n\ntype Note = {\n  id: string\n  created_by: string\n  title: string\n  body: string\n  updated_at: number\n}\n\nexport default async function RootLayout({\n  children\n}: {\n  children: React.ReactNode\n}) {\n  const notes = await kv.hgetall('notes')\n  let notesArray: Note[] = notes\n    ? (Object.values(notes) as Note[]).sort(\n        (a, b) => Number(b.id) - Number(a.id)\n      )\n    : []\n\n  return (\n    <html lang=\"en\">\n      <body>\n        <div className=\"container\">\n          <div className=\"banner\">\n            <a\n              href=\"https://nextjs.org/docs/app/building-your-application/rendering/server-components\"\n              target=\"_blank\"\n            >\n              Learn more →\n            </a>\n          </div>\n          <div className=\"main\">\n            <Sidebar notes={notesArray}>\n              <AuthButton noteId={null}>Add</AuthButton>\n            </Sidebar>\n            <section className=\"col note-viewer\">{children}</section>\n          </div>\n        </div>\n      </body>\n    </html>\n  )\n}\n"
  },
  {
    "path": "app/note/[id]/loading.tsx",
    "content": "export default function NoteSkeleton() {\n  return (\n    <div\n      className=\"note skeleton-container\"\n      role=\"progressbar\"\n      aria-busy=\"true\"\n    >\n      <div className=\"note-header\">\n        <div\n          className=\"note-title skeleton\"\n          style={{ height: '3rem', width: '65%', marginInline: '12px 1em' }}\n        />\n        <div\n          className=\"skeleton skeleton--button\"\n          style={{ width: '8em', height: '2.5em' }}\n        />\n      </div>\n      <div className=\"note-preview\">\n        <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n        <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n        <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n        <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n        <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "app/note/[id]/page.tsx",
    "content": "import { kv } from '../../kv-client'\nimport NoteUI from 'components/note-ui'\n\nexport const metadata = {\n  robots: {\n    index: false\n  }\n}\n\nexport default async function Page(props: { params: Promise<{ id: string }> }) {\n  const params = await props.params;\n  const note = await kv.hget('notes', params.id)\n\n  if (note === null) {\n    return (\n      <div className=\"note--empty-state\">\n        <span className=\"note-text--empty-state\">\n          Click a note on the left to view something! 🥺\n        </span>\n      </div>\n    )\n  }\n\n  return <NoteUI note={note} isEditing={false} />\n}\n"
  },
  {
    "path": "app/note/edit/[id]/page.tsx",
    "content": "import { kv } from '../../../kv-client'\nimport { cookies } from 'next/headers'\nimport { getUser, userCookieKey } from 'libs/session'\nimport NoteUI from 'components/note-ui'\n\nexport const metadata = {\n  robots: {\n    index: false\n  }\n}\n\ntype Note = {\n  id: string\n  created_by: string\n}\n\nexport default async function EditPage(props: { params: Promise<{ id: string }> }) {\n  const params = await props.params;\n  const cookieStore = await cookies()\n  const userCookie = cookieStore.get(userCookieKey)\n  const user = getUser(userCookie?.value)\n\n  const note = await kv.hget<Note>('notes', params.id)\n  const isCreator = note?.created_by === user || true\n\n  if (note === null) {\n    return (\n      <div className=\"note--empty-state\">\n        <span className=\"note-text--empty-state\">\n          Click a note on the left to view something! 🥺\n        </span>\n      </div>\n    )\n  }\n\n  return <NoteUI note={note} isEditing={isCreator} />\n}\n"
  },
  {
    "path": "app/note/edit/loading.tsx",
    "content": "export default function EditSkeleton() {\n  return (\n    <div\n      className=\"note-editor skeleton-container\"\n      role=\"progressbar\"\n      aria-busy=\"true\"\n    >\n      <div className=\"note-editor-form\">\n        <div className=\"skeleton v-stack\" style={{ height: '3rem' }} />\n        <div className=\"skeleton v-stack\" style={{ height: '100%' }} />\n      </div>\n      <div className=\"note-editor-preview\">\n        <div className=\"note-editor-menu\">\n          <div\n            className=\"skeleton skeleton--button\"\n            style={{ width: '8em', height: '2.5em' }}\n          />\n          <div\n            className=\"skeleton skeleton--button\"\n            style={{ width: '8em', height: '2.5em', marginInline: '12px 0' }}\n          />\n        </div>\n        <div\n          className=\"note-title skeleton\"\n          style={{ height: '3rem', width: '65%', marginInline: '12px 1em' }}\n        />\n        <div className=\"note-preview\">\n          <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n          <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n          <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n          <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n          <div className=\"skeleton v-stack\" style={{ height: '1.5em' }} />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "app/note/edit/page.tsx",
    "content": "import NoteUI from 'components/note-ui'\n\nexport const metadata = {\n  robots: {\n    index: false\n  }\n}\n\nexport default async function EditPage() {\n  const defaultNote = {\n    title: 'Untitled',\n    body: ''\n  }\n\n  return <NoteUI note={defaultNote} isEditing={true} />\n}\n"
  },
  {
    "path": "app/page.tsx",
    "content": "export default async function Page() {\n  return (\n    <div className=\"note--empty-state\">\n      <span className=\"note-text--empty-state\">\n        Click a note on the left to view something! 🥺\n      </span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "app/style.css",
    "content": "/* -------------------------------- CSSRESET --------------------------------*/\n/* CSS Reset adapted from https://dev.to/hankchizljaw/a-modern-css-reset-6p3 */\n/* Box sizing rules */\n*,\n*::before,\n*::after {\n  box-sizing: border-box;\n}\n\n/* Remove default padding */\nul[class],\nol[class] {\n  padding: 0;\n}\n\n/* Remove default margin */\nbody,\nh1,\nh2,\nh3,\nh4,\np,\nul[class],\nol[class],\nli,\nfigure,\nfigcaption,\nblockquote,\ndl,\ndd {\n  margin: 0;\n}\n\n/* Set core body defaults */\nbody {\n  scroll-behavior: smooth;\n  text-rendering: optimizeSpeed;\n  line-height: 1.5;\n}\n\n/* Remove list styles on ul, ol elements with a class attribute */\nul[class],\nol[class] {\n  list-style: none;\n}\n\n/* A elements that don't have a class get default styles */\na:not([class]) {\n  text-decoration-skip-ink: auto;\n}\n\n/* Make images easier to work with */\nimg {\n  max-width: 100%;\n  display: block;\n}\n\n/* Natural flow and rhythm in articles by default */\narticle > * + * {\n  margin-block-start: 1em;\n}\n\n/* Inherit fonts for inputs and buttons */\ninput,\nbutton,\ntextarea,\nselect {\n  font: inherit;\n  -webkit-tap-highlight-color: transparent;\n}\n\n/* Remove all animations and transitions for people that prefer not to see them */\n@media (prefers-reduced-motion: reduce) {\n  * {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n/* -------------------------------- /CSSRESET --------------------------------*/\n\n:root {\n  /* Colors */\n  --main-border-color: #ddd;\n  --primary-border: #037dba;\n  --gray-20: #404346;\n  --gray-60: #8a8d91;\n  --gray-70: #bcc0c4;\n  --gray-80: #c9ccd1;\n  --gray-90: #e4e6eb;\n  --gray-95: #f0f2f5;\n  --gray-100: #f5f7fa;\n  --primary-blue: #037dba;\n  --secondary-blue: #0396df;\n  --tertiary-blue: #c6efff;\n  --flash-blue: #4cf7ff;\n  --outline-blue: rgba(4, 164, 244, 0.6);\n  --navy-blue: #035e8c;\n  --red-25: #bd0d2a;\n  --secondary-text: #65676b;\n  --white: #fff;\n  --yellow: #fffae1;\n\n  --outline-box-shadow: 0 0 0 2px var(--outline-blue);\n  --outline-box-shadow-contrast: 0 0 0 2px var(--navy-blue);\n\n  /* Fonts */\n  --sans-serif: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto,\n    Ubuntu, Helvetica, sans-serif;\n  --monospace: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,\n    monospace;\n}\n\nhtml {\n  font-size: 100%;\n  height: 100%;\n}\n\nbody {\n  font-family: var(--sans-serif);\n  background: var(--gray-100);\n  font-weight: 400;\n  line-height: 1.75;\n  height: 100%;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5 {\n  margin: 0;\n  font-weight: 700;\n  line-height: 1.3;\n}\n\nh1 {\n  font-size: 3.052rem;\n}\nh2 {\n  font-size: 2.441rem;\n}\nh3 {\n  font-size: 1.953rem;\n}\nh4 {\n  font-size: 1.563rem;\n}\nh5 {\n  font-size: 1.25rem;\n}\nsmall,\n.text_small {\n  font-size: 0.8rem;\n}\npre,\ncode {\n  font-family: var(--monospace);\n  border-radius: 6px;\n}\npre {\n  background: var(--gray-95);\n  padding: 12px;\n  line-height: 1.5;\n  overflow: auto;\n}\ncode {\n  background: var(--yellow);\n  padding: 0 3px;\n  font-size: 0.94rem;\n  word-break: break-word;\n}\npre code {\n  background: none;\n}\na {\n  color: var(--primary-blue);\n}\n\n.text-with-markdown h1,\n.text-with-markdown h2,\n.text-with-markdown h3,\n.text-with-markdown h4,\n.text-with-markdown h5 {\n  margin-block: 2rem 0.7rem;\n  margin-inline: 0;\n}\n\n.text-with-markdown blockquote {\n  font-style: italic;\n  color: var(--gray-20);\n  border-left: 3px solid var(--gray-80);\n  padding-left: 10px;\n}\n\nhr {\n  border: 0;\n  height: 0;\n  border-top: 1px solid rgba(0, 0, 0, 0.1);\n  border-bottom: 1px solid rgba(255, 255, 255, 0.3);\n}\n\n/* ---------------------------------------------------------------------------*/\n#__next {\n  height: 100%;\n}\n\n.container {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n}\n\n.main {\n  flex: 1;\n  position: relative;\n  display: flex;\n  width: 100%;\n  overflow: hidden;\n}\n\n.col {\n  height: 100%;\n}\n.col:last-child {\n  flex-grow: 1;\n}\n\n.logo {\n  height: 20px;\n  width: 22px;\n  margin-inline-end: 10px;\n}\n\n.avatar {\n  width: 20px;\n  height: 20px;\n  display: inline-block;\n  border-radius: 100%;\n  border: 1px solid #ccc;\n  vertical-align: -5px;\n  margin-left: 5px;\n}\nbutton .avatar {\n  margin-right: -5px;\n}\n.edit-button {\n  border-radius: 100px;\n  letter-spacing: 0.12em;\n  text-transform: uppercase;\n  padding: 4px 14px;\n  cursor: pointer;\n  font-weight: 700;\n  font-size: 14px;\n  outline-style: none;\n  white-space: nowrap;\n  flex: 0 0 auto;\n}\n.edit-button--solid {\n  background: var(--primary-blue);\n  color: var(--white);\n  border: none;\n  margin: 0 0 0 10px;\n  transition: all 0.2s ease-in-out;\n}\n@media (hover: hover) {\n  .edit-button--solid:hover {\n    background: var(--secondary-blue);\n  }\n}\n.edit-button--solid:focus {\n  box-shadow: var(--outline-box-shadow-contrast);\n}\n.edit-button--outline {\n  background: var(--white);\n  color: var(--primary-blue);\n  border: 1px solid var(--primary-blue);\n  margin: 0 0 0 12px;\n  transition: all 0.1s ease-in-out;\n}\n.edit-button--outline:disabled {\n  opacity: 0.5;\n}\n@media (hover: hover) {\n  .edit-button--outline:hover:not([disabled]) {\n    background: var(--primary-blue);\n    color: var(--white);\n  }\n}\n.edit-button--outline:focus {\n  box-shadow: var(--outline-box-shadow);\n}\n\nul.notes-list {\n  padding: 16px 0;\n}\n.notes-list > li {\n  padding: 0 16px;\n}\n.notes-empty {\n  padding: 16px;\n}\n\n.sidebar {\n  background: var(--white);\n  box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.1), 0px 2px 2px rgba(0, 0, 0, 0.1);\n  overflow-y: scroll;\n  z-index: 1000;\n  flex-shrink: 0;\n  max-width: 350px;\n  min-width: 250px;\n  width: 30%;\n  height: auto;\n}\n.sidebar-toggle {\n  display: none;\n  position: fixed;\n  z-index: 9999;\n  top: 40px;\n  left: 10px;\n  margin: 0;\n  width: 30px;\n  height: 30px;\n  background: url(/x.svg) #e2e2e2;\n  border-radius: 6px;\n  border: none;\n  appearance: none;\n  background-size: 24px;\n  background-position: center;\n  background-repeat: no-repeat;\n}\n.sidebar-header {\n  letter-spacing: 0.12em;\n  text-transform: uppercase;\n  padding: 16px;\n  display: flex;\n  align-items: center;\n}\n.sidebar-menu {\n  padding: 0 16px;\n  display: flex;\n  justify-content: space-between;\n}\n.sidebar-menu > .search {\n  position: relative;\n  flex-grow: 1;\n}\n.sidebar-note-list-item {\n  position: relative;\n  margin-bottom: 12px;\n  padding: 16px;\n  width: 100%;\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  flex-wrap: wrap;\n  max-height: 100px;\n  transition: max-height 250ms ease-out;\n  transform: scale(1);\n}\n.sidebar-note-list-item.note-expanded {\n  max-height: 300px;\n  transition: max-height 0.5s ease;\n}\n.sidebar-note-list-item.flash {\n  animation-name: flash;\n  animation-duration: 0.6s;\n}\n\n.banner {\n  z-index: 2000;\n  width: 100%;\n  background: #1f1f1f;\n  color: #e6e6e6;\n  text-align: center;\n  padding: 3px;\n  font-size: 14px;\n  font-family: -apple-system, arial, sans-serif;\n  white-space: nowrap;\n  overflow: hidden;\n  letter-spacing: -0.02em;\n  flex: 0 0 auto;\n}\n.banner:before {\n  content: 'The Next.js App Router uses React Server Components by default. ';\n}\n.banner a {\n  color: #3aa3ff;\n}\n.screen-center {\n  height: 100vh;\n  width: 100vw;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n@media screen and (max-width: 640px) {\n  .banner:before {\n    content: 'Next.js App Router using React Server Components. ';\n  }\n  .banner {\n    font-size: 13px;\n  }\n  .sidebar-toggle {\n    display: block;\n  }\n  .sidebar {\n    position: absolute;\n    transition: all 0.4s ease;\n    height: 100%;\n  }\n  .sidebar-toggle:checked {\n    background: url(/menu.svg) #e2e2e2;\n    background-size: 24px;\n    background-position: center;\n    background-repeat: no-repeat;\n  }\n  .sidebar-toggle:checked + .sidebar {\n    transform: translateX(-120%);\n  }\n  .sidebar-toggle:not(:checked) ~ .note-viewer {\n    opacity: 0.2;\n    pointer-events: none;\n  }\n  .sidebar-header {\n    padding-top: 14px;\n    justify-content: flex-end;\n  }\n  .note-editor-done,\n  .note-editor-delete,\n  .edit-button {\n    padding: 3px 10px !important;\n    margin: 0 0 0 4px !important;\n    letter-spacing: 0.03em !important;\n    font-size: 13px !important;\n  }\n  .label {\n    padding: 4px 10px !important;\n    font-size: 13px !important;\n  }\n  input,\n  textarea {\n    font-size: 16px !important;\n  }\n}\n\n.sidebar-note-open {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  width: 100%;\n  z-index: 0;\n  border: none;\n  border-radius: 6px;\n  text-align: start;\n  background: var(--gray-95);\n  cursor: pointer;\n  outline-style: none;\n  color: transparent;\n  font-size: 0px;\n  appearance: none;\n}\n.sidebar-note-open:focus {\n  box-shadow: var(--outline-box-shadow);\n}\n@media (hover: hover) {\n  .sidebar-note-open:hover {\n    background: var(--gray-90);\n  }\n}\n.sidebar-note-header {\n  z-index: 1;\n  max-width: 85%;\n  pointer-events: none;\n}\n.sidebar-note-header > strong {\n  display: block;\n  font-size: 1.25rem;\n  line-height: 1.2;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n.sidebar-note-toggle-expand {\n  z-index: 2;\n  border-radius: 50%;\n  height: 24px;\n  width: 24px;\n  padding: 0 6px;\n  border: 1px solid var(--gray-60);\n  cursor: pointer;\n  flex-shrink: 0;\n  visibility: hidden;\n  opacity: 0;\n  cursor: default;\n  transition: visibility 0s linear 20ms, opacity 300ms;\n  outline-style: none;\n}\n.sidebar-note-toggle-expand:focus {\n  box-shadow: var(--outline-box-shadow);\n}\n\n.sidebar-note-open:focus + .sidebar-note-toggle-expand,\n.sidebar-note-toggle-expand:focus {\n  visibility: visible;\n  opacity: 1;\n  transition: visibility 0s linear 0s, opacity 300ms;\n}\n\n@media (hover: hover) {\n  .sidebar-note-open:hover + .sidebar-note-toggle-expand,\n  .sidebar-note-toggle-expand:hover {\n    visibility: visible;\n    opacity: 1;\n    transition: visibility 0s linear 0s, opacity 300ms;\n  }\n}\n.sidebar-note-toggle-expand img {\n  width: 10px;\n  height: 10px;\n}\n\n.sidebar-note-excerpt {\n  pointer-events: none;\n  z-index: 2;\n  flex: 1 1 250px;\n  color: var(--secondary-text);\n  position: relative;\n  animation: slideIn 100ms;\n}\n\n.search input {\n  padding: 0 16px;\n  border-radius: 100px;\n  border: 1px solid var(--gray-90);\n  width: 100%;\n  height: 100%;\n  outline-style: none;\n  appearance: none;\n}\n.search input:focus {\n  box-shadow: var(--outline-box-shadow);\n}\n.search .spinner {\n  position: absolute;\n  right: 10px;\n  top: 6px;\n}\n\n.note-viewer {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: auto;\n  max-width: 100%;\n  transition: opacity 0.3s ease;\n}\n.note {\n  background: var(--white);\n  box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1), 0px 0px 2px rgba(0, 0, 0, 0.1);\n  border-radius: 8px;\n  width: calc(100% - 32px);\n  height: calc(100% - 32px);\n  padding: 16px;\n  overflow-y: auto;\n}\n.note--empty-state {\n  margin: 20px;\n  text-align: center;\n}\n.note-text--empty-state {\n  font-size: 1.5rem;\n}\n.note-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  flex-wrap: wrap-reverse;\n}\n.note-menu {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  flex-grow: 1;\n  margin-bottom: 10px;\n  overflow: hidden;\n  width: 100%;\n  height: 36px;\n}\n.note-title {\n  line-height: 1.3;\n  flex-grow: 1;\n  overflow-wrap: break-word;\n  word-break: break-word;\n}\n.note-updated-at {\n  color: var(--secondary-text);\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  overflow: hidden;\n}\n.note-preview {\n  margin: 50px 0;\n}\n\n.note-editor {\n  display: flex;\n  padding: 16px;\n  background: var(--white);\n  box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1), 0px 0px 2px rgba(0, 0, 0, 0.1);\n  border-radius: 8px;\n  width: calc(100% - 32px);\n  height: calc(100% - 32px);\n  overflow-y: auto;\n}\n.note-editor .label {\n  margin-bottom: 10px;\n}\n.note-editor-form {\n  display: flex;\n  flex-direction: column;\n  width: 400px;\n  max-width: 50%;\n  flex-shrink: 0;\n  position: sticky;\n  top: 0;\n}\n.note-editor-form input,\n.note-editor-form textarea {\n  background: none;\n  border: 1px solid var(--gray-70);\n  border-radius: 4px;\n  font-family: var(--monospace);\n  font-size: 0.8rem;\n  padding: 12px;\n  outline-style: none;\n  appearance: none;\n}\n.note-editor-form input:focus,\n.note-editor-form textarea:focus {\n  box-shadow: var(--outline-box-shadow);\n}\n.note-editor-form input {\n  height: 44px;\n  margin-bottom: 16px;\n}\n.note-editor-form textarea {\n  height: 100%;\n  max-width: 400px;\n}\n.note-editor-menu {\n  display: flex;\n  justify-content: flex-end;\n  align-items: center;\n  margin-bottom: 10px;\n  float: right;\n}\n.note-editor-preview {\n  margin-inline-start: 40px;\n  width: 100%;\n}\n.note-editor-done,\n.note-editor-delete {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  border-radius: 100px;\n  letter-spacing: 0.12em;\n  text-transform: uppercase;\n  padding: 4px 14px;\n  cursor: pointer;\n  font-weight: 700;\n  font-size: 14px;\n  margin: 0 0 0 10px;\n  outline-style: none;\n  transition: all 0.2s ease-in-out;\n}\n.note-editor-done:disabled,\n.note-editor-delete:disabled {\n  opacity: 0.5;\n}\n.note-editor-done {\n  border: none;\n  background: var(--primary-blue);\n  color: var(--white);\n}\n.note-editor-done:focus {\n  box-shadow: var(--outline-box-shadow-contrast);\n}\n@media (hover: hover) {\n  .note-editor-done:hover:not([disabled]) {\n    background: var(--secondary-blue);\n  }\n}\n.note-editor-delete {\n  border: 1px solid var(--red-25);\n  background: var(--white);\n  color: var(--red-25);\n}\n.note-editor-delete:focus {\n  box-shadow: var(--outline-box-shadow);\n}\n@media (hover: hover) {\n  .note-editor-delete:hover:not([disabled]) {\n    background: var(--red-25);\n    color: var(--white);\n  }\n  /* Hack to color our svg */\n  .note-editor-delete:hover:not([disabled]) img {\n    filter: grayscale(1) invert(1) brightness(2);\n  }\n}\n.note-editor-done > img {\n  width: 14px;\n}\n.note-editor-delete > img {\n  width: 10px;\n}\n.note-editor-done > img,\n.note-editor-delete > img {\n  margin-inline-end: 12px;\n}\n.note-editor-done[disabled],\n.note-editor-delete[disabled] {\n  opacity: 0.5;\n}\n\n.label {\n  display: inline-block;\n  border-radius: 100px;\n  text-transform: uppercase;\n  font-weight: 700;\n  font-size: 14px;\n  padding: 5px 14px;\n}\n.label--preview {\n  background: rgba(38, 183, 255, 0.15);\n  color: var(--primary-blue);\n}\n\n.text-with-markdown p {\n  margin-bottom: 16px;\n  white-space: break-spaces;\n}\n.text-with-markdown img {\n  width: 100%;\n}\n\n/* https://codepen.io/mandelid/pen/vwKoe */\n.spinner {\n  display: inline-block;\n  transition: opacity linear 0.1s;\n  width: 20px;\n  height: 20px;\n  border: 3px solid rgba(80, 80, 80, 0.5);\n  border-radius: 50%;\n  border-top-color: #fff;\n  animation: spin 1s ease-in-out infinite;\n  opacity: 0;\n}\n.spinner--active {\n  opacity: 1;\n}\n\n.skeleton::after {\n  content: 'Loading...';\n}\n.skeleton {\n  height: 100%;\n  background-color: #eee;\n  background-image: linear-gradient(90deg, #eee, #f5f5f5, #eee);\n  background-size: 200px 100%;\n  background-repeat: no-repeat;\n  border-radius: 4px;\n  display: block;\n  line-height: 1;\n  width: 100%;\n  animation: shimmer 1.2s ease-in-out infinite;\n  color: transparent;\n}\n.skeleton:first-of-type {\n  margin: 0;\n}\n.skeleton--button {\n  border-radius: 100px;\n  padding: 6px 20px;\n  width: auto;\n}\n.v-stack + .v-stack {\n  margin-block-start: 0.8em;\n}\n\n.offscreen {\n  border: 0;\n  clip: rect(0, 0, 0, 0);\n  height: 1px;\n  margin: -1px;\n  overflow: hidden;\n  padding: 0;\n  width: 1px;\n  position: absolute;\n}\n\n.link--unstyled,\n.link--unstyled:active,\n.link--unstyled:visited,\n.link--unstyled:focus {\n  text-decoration: none;\n  color: unset;\n}\n\n@media screen and (max-width: 900px) {\n  .note-editor {\n    flex-direction: column-reverse;\n  }\n  .note-editor-preview {\n    margin-inline-start: 0;\n    overflow: auto;\n    margin-bottom: 20px;\n    flex: 2;\n  }\n  .note-editor-form {\n    flex: 1;\n    width: 100%;\n    max-width: 100%;\n  }\n  .note-editor-form textarea {\n    max-width: 100%;\n  }\n}\n\n/* ---------------------------------------------------------------------------*/\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n@keyframes shimmer {\n  0% {\n    background-position: -200px 0;\n  }\n  100% {\n    background-position: calc(200px + 100%) 0;\n  }\n}\n\n@keyframes slideIn {\n  0% {\n    top: -10px;\n    opacity: 0;\n  }\n  100% {\n    top: 0;\n    opacity: 1;\n  }\n}\n\n@keyframes flash {\n  0% {\n    transform: scale(1);\n    opacity: 1;\n  }\n  50% {\n    transform: scale(1.05);\n    opacity: 0.9;\n  }\n  100% {\n    transform: scale(1);\n    opacity: 1;\n  }\n}\n"
  },
  {
    "path": "components/auth-button.tsx",
    "content": "import Link from 'next/link'\nimport { cookies } from 'next/headers'\nimport { getUser, userCookieKey } from 'libs/session'\n\nexport default async function AuthButton({\n  children,\n  noteId\n}: {\n  children: React.ReactNode\n  noteId: string | null\n}) {\n  const cookieStore = await cookies()\n  const userCookie = cookieStore.get(userCookieKey)\n  const user = getUser(userCookie?.value)\n  const isDraft = noteId == null\n\n  if (user) {\n    return (\n      // Use hard link\n      <a href={`/note/edit/${noteId || ''}`} className=\"link--unstyled\">\n        <button\n          className={[\n            'edit-button',\n            isDraft ? 'edit-button--solid' : 'edit-button--outline'\n          ].join(' ')}\n          role=\"menuitem\"\n        >\n          {children}\n          <img\n            src={`https://avatars.githubusercontent.com/${user}?s=40`}\n            alt=\"User Avatar\"\n            title={user}\n            className=\"avatar\"\n          />\n        </button>\n      </a>\n    )\n  }\n\n  return (\n    <Link href=\"/auth\" className=\"link--unstyled\">\n      <button\n        className={[\n          'edit-button',\n          isDraft ? 'edit-button--solid' : 'edit-button--outline'\n        ].join(' ')}\n        role=\"menuitem\"\n      >\n        Login to Add\n      </button>\n    </Link>\n  )\n}\n"
  },
  {
    "path": "components/note-editor.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport NotePreview from './note-preview'\nimport { useFormStatus } from 'react-dom'\nimport { deleteNote, saveNote } from '../app/actions'\n\nexport default function NoteEditor({\n  noteId,\n  initialTitle,\n  initialBody\n}: {\n  noteId: string | null\n  initialTitle: string\n  initialBody: string\n}) {\n  const { pending } = useFormStatus()\n  const [title, setTitle] = useState(initialTitle)\n  const [body, setBody] = useState(initialBody)\n  const isDraft = !noteId\n\n  return (\n    <div className=\"note-editor\">\n      <form className=\"note-editor-form\" autoComplete=\"off\">\n        <label className=\"offscreen\" htmlFor=\"note-title-input\">\n          Enter a title for your note\n        </label>\n        <input\n          id=\"note-title-input\"\n          type=\"text\"\n          value={title}\n          onChange={(e) => {\n            setTitle(e.target.value)\n          }}\n        />\n        <label className=\"offscreen\" htmlFor=\"note-body-input\">\n          Enter the body for your note\n        </label>\n        <textarea\n          value={body}\n          id=\"note-body-input\"\n          onChange={(e) => setBody(e.target.value)}\n        />\n      </form>\n      <div className=\"note-editor-preview\">\n        <form className=\"note-editor-menu\" role=\"menubar\">\n          <button\n            className=\"note-editor-done\"\n            disabled={pending}\n            type=\"submit\"\n            formAction={() => saveNote(noteId, title, body)}\n            role=\"menuitem\"\n          >\n            <img\n              src=\"/checkmark.svg\"\n              width=\"14px\"\n              height=\"10px\"\n              alt=\"\"\n              role=\"presentation\"\n            />\n            Done\n          </button>\n          {!isDraft && (\n            <button\n              className=\"note-editor-delete\"\n              disabled={pending}\n              formAction={() => deleteNote(noteId)}\n              role=\"menuitem\"\n            >\n              <img\n                src=\"/cross.svg\"\n                width=\"10px\"\n                height=\"10px\"\n                alt=\"\"\n                role=\"presentation\"\n              />\n              Delete\n            </button>\n          )}\n        </form>\n        <div className=\"label label--preview\" role=\"status\">\n          Preview\n        </div>\n        <h1 className=\"note-title\">{title}</h1>\n        <NotePreview>{body}</NotePreview>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "components/note-list-skeleton.tsx",
    "content": "export default function NoteListSkeleton() {\n  return (\n    <div>\n      <ul className=\"notes-list skeleton-container\">\n        <li className=\"v-stack\">\n          <div\n            className=\"sidebar-note-list-item skeleton\"\n            style={{ height: '5em' }}\n          />\n        </li>\n        <li className=\"v-stack\">\n          <div\n            className=\"sidebar-note-list-item skeleton\"\n            style={{ height: '5em' }}\n          />\n        </li>\n        <li className=\"v-stack\">\n          <div\n            className=\"sidebar-note-list-item skeleton\"\n            style={{ height: '5em' }}\n          />\n        </li>\n      </ul>\n    </div>\n  )\n}\n"
  },
  {
    "path": "components/note-list.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport { format, isToday } from 'date-fns'\nimport marked from 'marked'\nimport ClientSidebarNote from './sidebar-note'\nimport { load } from 'cheerio'\n\nexport default function NoteList({ notes, searchText }) {\n  if (notes.length === 0) {\n    return (\n      <div className=\"notes-empty\">\n        {searchText\n          ? `Couldn't find any notes titled \"${searchText}\".`\n          : 'No notes created yet!'}{' '}\n      </div>\n    )\n  }\n\n  return (\n    <ul className=\"notes-list\">\n      {notes.map((note) =>\n        note &&\n        (!searchText ||\n          note.title.toLowerCase().includes(searchText.toLowerCase())) ? (\n          <li key={note.id}>\n            <SidebarNote note={note} />\n          </li>\n        ) : null\n      )}\n    </ul>\n  )\n}\n\nfunction excerpts(html, length) {\n  const text = load(html)\n    .text()\n    .trim()\n    .replace(/(\\r\\n|\\r|\\n|\\s)+/g, ' ')\n\n  let excerpt = ''\n  if (length) {\n    excerpt = text.split(' ').slice(0, length).join(' ')\n  }\n  if (excerpt.length < text.length) excerpt += '...'\n\n  return excerpt\n}\n\nfunction SidebarNote({ note }) {\n  const updatedAt = new Date(note.updated_at)\n  const lastUpdatedAt = isToday(updatedAt)\n    ? format(updatedAt, 'h:mm bb')\n    : format(updatedAt, 'M/d/yy')\n  const summary = excerpts(marked(note.body || ''), 20)\n\n  return (\n    <ClientSidebarNote\n      id={note.id}\n      title={note.title}\n      expandedChildren={\n        <p className=\"sidebar-note-excerpt\">{summary || <i>(No content)</i>}</p>\n      }\n    >\n      <header className=\"sidebar-note-header\">\n        <strong>{note.title}</strong>\n        <small>{lastUpdatedAt}</small>\n      </header>\n    </ClientSidebarNote>\n  )\n}\n"
  },
  {
    "path": "components/note-preview.tsx",
    "content": "import marked from 'marked'\nimport sanitizeHtml from 'sanitize-html'\n\nconst allowedTags = sanitizeHtml.defaults.allowedTags.concat([\n  'img',\n  'h1',\n  'h2',\n  'h3'\n])\nconst allowedAttributes = Object.assign(\n  {},\n  sanitizeHtml.defaults.allowedAttributes,\n  {\n    img: ['alt', 'src']\n  }\n)\n\nexport default function NotePreview({ children }) {\n  return (\n    <div className=\"note-preview\">\n      <div\n        className=\"text-with-markdown\"\n        dangerouslySetInnerHTML={{\n          __html: sanitizeHtml(marked(children || ''), {\n            allowedTags,\n            allowedAttributes\n          })\n        }}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "components/note-ui.tsx",
    "content": "import { format } from 'date-fns'\nimport NotePreview from 'components/note-preview'\nimport NoteEditor from 'components/note-editor'\nimport AuthButton from 'components/auth-button'\nimport { cookies } from 'next/headers'\nimport { getUser, userCookieKey } from 'libs/session'\n\nexport default async function NoteUI({ note, isEditing }) {\n  const cookieStore = await cookies()\n  const userCookie = cookieStore.get(userCookieKey)\n  const user = getUser(userCookie?.value)\n  const { id, title, body, updated_at, created_by: createdBy } = note\n  const updatedAt = new Date(updated_at || 0)\n\n  if (isEditing) {\n    return <NoteEditor noteId={id} initialTitle={title} initialBody={body} />\n  }\n\n  return (\n    <div className=\"note\">\n      <div className=\"note-header\">\n        <h1 className=\"note-title\">{title}</h1>\n        {createdBy ? (\n          <div\n            style={{\n              flex: '1 0 100%',\n              order: '-1',\n              marginTop: 10\n            }}\n          >\n            By{' '}\n            <img\n              src={`https://avatars.githubusercontent.com/${createdBy}?s=40`}\n              alt=\"User Avatar\"\n              title={createdBy}\n              className=\"avatar\"\n            />\n            &nbsp;\n            <a\n              href={`https://github.com/${createdBy}`}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n            >\n              {createdBy}\n            </a>\n          </div>\n        ) : null}\n        <div className=\"note-menu\" role=\"menubar\">\n          <small className=\"note-updated-at\" role=\"status\">\n            Last updated on {format(updatedAt, \"d MMM yyyy 'at' h:mm bb\")}\n          </small>\n          {user === createdBy ? (\n            <AuthButton noteId={id}>Edit</AuthButton>\n          ) : (\n            <div style={{ height: 30 }} />\n          )}\n        </div>\n      </div>\n      <NotePreview>{body}</NotePreview>\n    </div>\n  )\n}\n"
  },
  {
    "path": "components/search.tsx",
    "content": "'use client'\n\nimport { usePathname, useRouter } from 'next/navigation'\nimport { useTransition } from 'react'\n\nexport default function SearchField() {\n  const { replace } = useRouter()\n  const pathname = usePathname()\n  const [isPending, startTransition] = useTransition()\n\n  function handleSearch(term: string) {\n    const params = new URLSearchParams(window.location.search)\n    if (term) {\n      params.set('q', term)\n    } else {\n      params.delete('q')\n    }\n\n    startTransition(() => {\n      replace(`${pathname}?${params.toString()}`)\n    })\n  }\n\n  return (\n    <div className=\"search\" role=\"search\">\n      <label className=\"offscreen\" htmlFor=\"sidebar-search-input\">\n        Search for a note by title\n      </label>\n      <input\n        id=\"sidebar-search-input\"\n        type=\"text\"\n        name=\"search\"\n        placeholder=\"Search\"\n        spellCheck={false}\n        onChange={(e) => handleSearch(e.target.value)}\n      />\n      <Spinner active={isPending} />\n    </div>\n  )\n}\n\nfunction Spinner({ active = true }) {\n  return (\n    <div\n      className={['spinner', active && 'spinner--active'].join(' ')}\n      role=\"progressbar\"\n      aria-busy={active ? 'true' : 'false'}\n    />\n  )\n}\n"
  },
  {
    "path": "components/sidebar-note.tsx",
    "content": "'use client'\n\nimport { useState, useRef, useEffect, useTransition } from 'react'\nimport { useRouter, usePathname, useSearchParams } from 'next/navigation'\n\nexport default function SidebarNote({ id, title, children, expandedChildren }) {\n  const router = useRouter()\n  const pathname = usePathname()\n  const searchParams = useSearchParams()\n  const selectedId = pathname?.split('/')[1] || null\n  const [isPending] = useTransition()\n  const [isExpanded, setIsExpanded] = useState(false)\n  const isActive = id === selectedId\n\n  // Animate after title is edited\n  const itemRef = useRef(null)\n  const prevTitleRef = useRef(title)\n\n  useEffect(() => {\n    if (title !== prevTitleRef.current) {\n      prevTitleRef.current = title\n      // @ts-ignore\n      itemRef.current.classList.add('flash')\n    }\n  }, [title])\n\n  return (\n    <div\n      ref={itemRef}\n      onAnimationEnd={() => {\n        // @ts-ignore\n        itemRef.current.classList.remove('flash')\n      }}\n      className={[\n        'sidebar-note-list-item',\n        isExpanded ? 'note-expanded' : ''\n      ].join(' ')}\n    >\n      {children}\n      <button\n        className=\"sidebar-note-open\"\n        style={{\n          backgroundColor: isPending\n            ? 'var(--gray-80)'\n            : isActive\n              ? 'var(--tertiary-blue)'\n              : undefined,\n          border: isActive\n            ? '1px solid var(--primary-border)'\n            : '1px solid transparent'\n        }}\n        onClick={() => {\n          // hide the sidebar\n          const sidebarToggle = document.getElementById('sidebar-toggle')\n          if (sidebarToggle) {\n            // @ts-ignore\n            sidebarToggle.checked = true\n          }\n\n          const url = new URL(`/note/${id}`, window.location.origin)\n          searchParams.forEach((value, key) => {\n            url.searchParams.append(key, value)\n          })\n\n          router.push(url.pathname + url.search)\n        }}\n      >\n        Open note for preview\n      </button>\n      <button\n        className=\"sidebar-note-toggle-expand\"\n        onClick={(e) => {\n          e.stopPropagation()\n          setIsExpanded(!isExpanded)\n        }}\n      >\n        {isExpanded ? (\n          <img\n            src=\"/chevron-down.svg\"\n            width=\"10px\"\n            height=\"10px\"\n            alt=\"Collapse\"\n          />\n        ) : (\n          <img src=\"/chevron-up.svg\" width=\"10px\" height=\"10px\" alt=\"Expand\" />\n        )}\n      </button>\n      {isExpanded && expandedChildren}\n    </div>\n  )\n}\n"
  },
  {
    "path": "components/sidebar.tsx",
    "content": "'use client'\n\nimport React, { Suspense } from 'react'\nimport Link from 'next/link'\nimport { useSearchParams } from 'next/navigation'\nimport SearchField from 'components/search'\nimport NoteList from 'components/note-list'\nimport NoteListSkeleton from 'components/note-list-skeleton'\n\ntype Note = {\n  id: string\n  created_by: string\n  title: string\n  body: string\n  updated_at: number\n}\n\nexport default function Sidebar({\n  children,\n  notes\n}: {\n  children: React.ReactNode\n  notes: Note[]\n}) {\n  return (\n    <>\n      <input type=\"checkbox\" className=\"sidebar-toggle\" id=\"sidebar-toggle\" />\n      <section className=\"col sidebar\">\n        <Link href={'/'} className=\"link--unstyled\">\n          <section className=\"sidebar-header\">\n            <img\n              className=\"logo\"\n              src=\"/logo.svg\"\n              width=\"22px\"\n              height=\"20px\"\n              alt=\"\"\n              role=\"presentation\"\n            />\n            <strong>React Notes</strong>\n          </section>\n        </Link>\n        <section className=\"sidebar-menu\" role=\"menubar\">\n          <SearchField />\n          {children}\n        </section>\n        <nav>\n          <Suspense fallback={<NoteListSkeleton />}>\n            <Notes notes={notes} />\n          </Suspense>\n        </nav>\n      </section>\n    </>\n  )\n}\n\nfunction Notes({ notes }: { notes: Note[] }) {\n  const searchParams = useSearchParams()\n  const search = searchParams.get('q')\n\n  return <NoteList notes={notes} searchText={search} />\n}\n"
  },
  {
    "path": "libs/session.ts",
    "content": "export const userCookieKey = '_un'\nexport const cookieSep = '^)&_*($'\n\nconst iv = encode('encryptiv')\nconst password = process.env.SESSION_KEY\n\nconst pwUtf8 = encode(password)\nconst algo = { name: 'AES-GCM', iv }\n\nfunction encode(value) {\n  return new TextEncoder().encode(value)\n}\n\nfunction decode(value) {\n  return new TextDecoder().decode(value)\n}\n\nfunction base64ToArrayBuffer(base64) {\n  const binary = atob(base64)\n  const len = binary.length\n  const bytes = new Uint8Array(len)\n  for (let i = 0; i < len; i++) {\n    bytes[i] = binary.charCodeAt(i)\n  }\n  return bytes.buffer\n}\n\nfunction arrayBufferToBase64(buffer) {\n  const bytes = new Uint8Array(buffer)\n  // @ts-ignore\n  const binary = String.fromCharCode(...bytes)\n  return btoa(binary)\n}\n\n// Encrypt\nexport function createEncrypt() {\n  return async function (data) {\n    const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)\n    const encryptKey = await crypto.subtle.importKey(\n      'raw',\n      pwHash,\n      algo,\n      false,\n      ['encrypt']\n    )\n    const encrypted = await crypto.subtle.encrypt(\n      algo,\n      encryptKey,\n      encode(data)\n    )\n    return arrayBufferToBase64(encrypted)\n  }\n}\n\n// Decrypt\nexport function createDecrypt() {\n  return async function decrypt(data) {\n    const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)\n    const buffer = base64ToArrayBuffer(data)\n    const decryptKey = await crypto.subtle.importKey(\n      'raw',\n      pwHash,\n      algo,\n      false,\n      ['decrypt']\n    )\n    const ptBuffer = await crypto.subtle.decrypt(algo, decryptKey, buffer)\n    const decryptedText = decode(ptBuffer)\n    return decryptedText\n  }\n}\n\nexport function getSession(userCookie) {\n  const none = [null, null]\n  const value = decodeURIComponent(userCookie)\n  if (!value) return none\n  const index = value.indexOf(cookieSep)\n  if (index === -1) return none\n  const user = value.slice(0, index)\n  const session = value.slice(index + cookieSep.length)\n  return [user, session]\n}\n\nexport function getUser(userCookie) {\n  return getSession(userCookie)[0]\n}\n"
  },
  {
    "path": "middleware/api.ts",
    "content": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\nimport { getUser, userCookieKey } from 'libs/session'\n\nexport default function middleware(req: NextRequest) {\n  const userCookie = req.cookies.get(userCookieKey)?.value\n  const user = getUser(userCookie)\n\n  if (req.method !== 'GET' && !user) {\n    return NextResponse.json({ message: 'Unauthorized' }, { status: 403 })\n  }\n\n  return NextResponse.next()\n}\n"
  },
  {
    "path": "middleware/auth.ts",
    "content": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\nimport { userCookieKey, cookieSep, createEncrypt } from 'libs/session'\n\nconst CLIENT_ID = process.env.OAUTH_CLIENT_KEY\nconst CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET\n\nexport default async function middleware(req: NextRequest) {\n  const { nextUrl } = req\n  const { searchParams } = nextUrl\n  const query = Object.fromEntries(searchParams)\n  const encrypt = createEncrypt()\n  const { code } = query\n\n  // When there's no `code` param specified,\n  // it's a GET from the client side.\n  // We go with the login flow.\n  if (!code) {\n    // Login with GitHub\n    const redirectUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&allow_signup=false`\n    return NextResponse.redirect(redirectUrl)\n  }\n\n  let token = ''\n  try {\n    const data = await (\n      await fetch('https://github.com/login/oauth/access_token', {\n        method: 'POST',\n        body: JSON.stringify({\n          client_id: CLIENT_ID,\n          client_secret: CLIENT_SECRET,\n          code\n        }),\n        headers: {\n          Accept: 'application/json',\n          'Content-Type': 'application/json'\n        }\n      })\n    ).json()\n\n    const accessToken = data.access_token\n\n    // Let's also fetch the user info and store it in the session.\n    if (accessToken) {\n      const userInfo = await (\n        await fetch('https://api.github.com/user', {\n          method: 'GET',\n          headers: {\n            Authorization: `token ${accessToken}`,\n            Accept: 'application/json'\n          }\n        })\n      ).json()\n\n      token = userInfo.login\n    }\n  } catch (err) {\n    console.error(err)\n\n    return NextResponse.json(\n      { message: err.toString() },\n      {\n        status: 500\n      }\n    )\n  }\n\n  if (!token) {\n    return NextResponse.json(\n      { message: 'Github authorization failed' },\n      {\n        status: 400\n      }\n    )\n  }\n\n  const user = {\n    name: token,\n    encrypted: await encrypt(token)\n  }\n\n  const url = req.nextUrl.clone()\n  url.searchParams.delete('code')\n  url.pathname = '/'\n\n  const res = NextResponse.redirect(url)\n\n  res.cookies.set(\n    userCookieKey,\n    `${user.name}${cookieSep}${user.encrypted}; Secure; HttpOnly`\n  )\n\n  return res\n}\n"
  },
  {
    "path": "middleware/edit.ts",
    "content": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\nimport { createDecrypt, getSession, userCookieKey } from 'libs/session'\n\nexport default async function middleware(req: NextRequest) {\n  const decrypt = createDecrypt()\n  const cookie = req.cookies.get(userCookieKey)?.value\n  const [userCookie, sessionCookie] = getSession(cookie)\n\n  let login: string | null = null\n  let authErr = null\n\n  if (sessionCookie && userCookie) {\n    try {\n      login = await decrypt(sessionCookie)\n    } catch (e) {\n      console.error(e)\n      authErr = e\n    }\n\n    if (!authErr && login === userCookie) {\n      return NextResponse.next()\n    }\n  }\n\n  const url = req.nextUrl.clone()\n  url.pathname = '/'\n  return NextResponse.redirect(url)\n}\n"
  },
  {
    "path": "middleware/logout.ts",
    "content": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\nimport { userCookieKey } from 'libs/session'\n\nexport default async function middleware(req: NextRequest) {\n  const url = req.nextUrl.clone()\n  url.pathname = '/'\n  const res = NextResponse.redirect(url.toString(), 302)\n\n  res.cookies.set(\n    userCookieKey,\n    `deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`\n  )\n\n  return res\n}\n"
  },
  {
    "path": "middleware.ts",
    "content": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport apiMiddleware from 'middleware/api'\nimport authMiddleware from 'middleware/auth'\nimport editMiddleware from 'middleware/edit'\nimport logoutMiddleware from 'middleware/logout'\n\nfunction matchPathname(url, pathname) {\n  return url.pathname.startsWith(pathname)\n}\n\nexport async function middleware(req: NextRequest) {\n  const url = req.nextUrl.clone()\n  if (matchPathname(url, '/api')) {\n    return apiMiddleware(req)\n  }\n\n  if (matchPathname(url, '/edit')) {\n    return editMiddleware(req)\n  }\n\n  if (matchPathname(url, '/logout')) {\n    return logoutMiddleware(req)\n  }\n\n  if (matchPathname(url, '/auth')) {\n    return authMiddleware(req)\n  }\n\n  return NextResponse.next()\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev --turbopack\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"format\": \"prettier --write .\"\n  },\n  \"dependencies\": {\n    \"@types/node\": \"^22.8.7\",\n    \"@types/react\": \"npm:types-react@19.0.0-rc.1\",\n    \"@vercel/kv\": \"^3.0.0\",\n    \"cheerio\": \"1.0.0\",\n    \"date-fns\": \"^4.1.0\",\n    \"marked\": \"^1.2.9\",\n    \"ms\": \"2.1.3\",\n    \"next\": \"^15.0.5\",\n    \"oauth\": \"^0.10.0\",\n    \"postcss\": \"^8.4.47\",\n    \"react\": \"19.0.0-rc-02c0e824-20241028\",\n    \"react-dom\": \"19.0.0-rc-02c0e824-20241028\",\n    \"sanitize-html\": \"^2.13.1\",\n    \"typescript\": \"^5.6.3\"\n  },\n  \"prettier\": {\n    \"arrowParens\": \"always\",\n    \"singleQuote\": true,\n    \"semi\": false,\n    \"tabWidth\": 2,\n    \"trailingComma\": \"none\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"@types/react\": \"npm:types-react@19.0.0-rc.1\"\n    }\n  }\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": false,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"baseUrl\": \".\",\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"incremental\": true,\n    \"module\": \"esnext\",\n    \"strictNullChecks\": true,\n    \"target\": \"ES2017\"\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \".next/types/**/*.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\"\n  ],\n  \"exclude\": [\n    \"node_modules\"\n  ]\n}\n"
  }
]