main 91d5cbde29e3 cached
30 files
47.6 KB
14.1k tokens
41 symbols
1 requests
Download .txt
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://:<password>@<url>:<port>' # 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)](<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>)

## 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 (
    <html lang="en">
      <body>
        <div className="container">
          <div className="banner">
            <a
              href="https://nextjs.org/docs/app/building-your-application/rendering/server-components"
              target="_blank"
            >
              Learn more →
            </a>
          </div>
          <div className="main">
            <Sidebar notes={notesArray}>
              <AuthButton noteId={null}>Add</AuthButton>
            </Sidebar>
            <section className="col note-viewer">{children}</section>
          </div>
        </div>
      </body>
    </html>
  )
}


================================================
FILE: app/note/[id]/loading.tsx
================================================
export default function NoteSkeleton() {
  return (
    <div
      className="note skeleton-container"
      role="progressbar"
      aria-busy="true"
    >
      <div className="note-header">
        <div
          className="note-title skeleton"
          style={{ height: '3rem', width: '65%', marginInline: '12px 1em' }}
        />
        <div
          className="skeleton skeleton--button"
          style={{ width: '8em', height: '2.5em' }}
        />
      </div>
      <div className="note-preview">
        <div className="skeleton v-stack" style={{ height: '1.5em' }} />
        <div className="skeleton v-stack" style={{ height: '1.5em' }} />
        <div className="skeleton v-stack" style={{ height: '1.5em' }} />
        <div className="skeleton v-stack" style={{ height: '1.5em' }} />
        <div className="skeleton v-stack" style={{ height: '1.5em' }} />
      </div>
    </div>
  )
}


================================================
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 (
      <div className="note--empty-state">
        <span className="note-text--empty-state">
          Click a note on the left to view something! 🥺
        </span>
      </div>
    )
  }

  return <NoteUI note={note} isEditing={false} />
}


================================================
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<Note>('notes', params.id)
  const isCreator = note?.created_by === user || true

  if (note === null) {
    return (
      <div className="note--empty-state">
        <span className="note-text--empty-state">
          Click a note on the left to view something! 🥺
        </span>
      </div>
    )
  }

  return <NoteUI note={note} isEditing={isCreator} />
}


================================================
FILE: app/note/edit/loading.tsx
================================================
export default function EditSkeleton() {
  return (
    <div
      className="note-editor skeleton-container"
      role="progressbar"
      aria-busy="true"
    >
      <div className="note-editor-form">
        <div className="skeleton v-stack" style={{ height: '3rem' }} />
        <div className="skeleton v-stack" style={{ height: '100%' }} />
      </div>
      <div className="note-editor-preview">
        <div className="note-editor-menu">
          <div
            className="skeleton skeleton--button"
            style={{ width: '8em', height: '2.5em' }}
          />
          <div
            className="skeleton skeleton--button"
            style={{ width: '8em', height: '2.5em', marginInline: '12px 0' }}
          />
        </div>
        <div
          className="note-title skeleton"
          style={{ height: '3rem', width: '65%', marginInline: '12px 1em' }}
        />
        <div className="note-preview">
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
        </div>
      </div>
    </div>
  )
}


================================================
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 <NoteUI note={defaultNote} isEditing={true} />
}


================================================
FILE: app/page.tsx
================================================
export default async function Page() {
  return (
    <div className="note--empty-state">
      <span className="note-text--empty-state">
        Click a note on the left to view something! 🥺
      </span>
    </div>
  )
}


================================================
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
      <a href={`/note/edit/${noteId || ''}`} className="link--unstyled">
        <button
          className={[
            'edit-button',
            isDraft ? 'edit-button--solid' : 'edit-button--outline'
          ].join(' ')}
          role="menuitem"
        >
          {children}
          <img
            src={`https://avatars.githubusercontent.com/${user}?s=40`}
            alt="User Avatar"
            title={user}
            className="avatar"
          />
        </button>
      </a>
    )
  }

  return (
    <Link href="/auth" className="link--unstyled">
      <button
        className={[
          'edit-button',
          isDraft ? 'edit-button--solid' : 'edit-button--outline'
        ].join(' ')}
        role="menuitem"
      >
        Login to Add
      </button>
    </Link>
  )
}


================================================
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 (
    <div className="note-editor">
      <form className="note-editor-form" autoComplete="off">
        <label className="offscreen" htmlFor="note-title-input">
          Enter a title for your note
        </label>
        <input
          id="note-title-input"
          type="text"
          value={title}
          onChange={(e) => {
            setTitle(e.target.value)
          }}
        />
        <label className="offscreen" htmlFor="note-body-input">
          Enter the body for your note
        </label>
        <textarea
          value={body}
          id="note-body-input"
          onChange={(e) => setBody(e.target.value)}
        />
      </form>
      <div className="note-editor-preview">
        <form className="note-editor-menu" role="menubar">
          <button
            className="note-editor-done"
            disabled={pending}
            type="submit"
            formAction={() => saveNote(noteId, title, body)}
            role="menuitem"
          >
            <img
              src="/checkmark.svg"
              width="14px"
              height="10px"
              alt=""
              role="presentation"
            />
            Done
          </button>
          {!isDraft && (
            <button
              className="note-editor-delete"
              disabled={pending}
              formAction={() => deleteNote(noteId)}
              role="menuitem"
            >
              <img
                src="/cross.svg"
                width="10px"
                height="10px"
                alt=""
                role="presentation"
              />
              Delete
            </button>
          )}
        </form>
        <div className="label label--preview" role="status">
          Preview
        </div>
        <h1 className="note-title">{title}</h1>
        <NotePreview>{body}</NotePreview>
      </div>
    </div>
  )
}


================================================
FILE: components/note-list-skeleton.tsx
================================================
export default function NoteListSkeleton() {
  return (
    <div>
      <ul className="notes-list skeleton-container">
        <li className="v-stack">
          <div
            className="sidebar-note-list-item skeleton"
            style={{ height: '5em' }}
          />
        </li>
        <li className="v-stack">
          <div
            className="sidebar-note-list-item skeleton"
            style={{ height: '5em' }}
          />
        </li>
        <li className="v-stack">
          <div
            className="sidebar-note-list-item skeleton"
            style={{ height: '5em' }}
          />
        </li>
      </ul>
    </div>
  )
}


================================================
FILE: components/note-list.tsx
================================================
'use client'

import React from 'react'
import { format, isToday } from 'date-fns'
import marked from 'marked'
import ClientSidebarNote from './sidebar-note'
import { load } from 'cheerio'

export default function NoteList({ notes, searchText }) {
  if (notes.length === 0) {
    return (
      <div className="notes-empty">
        {searchText
          ? `Couldn't find any notes titled "${searchText}".`
          : 'No notes created yet!'}{' '}
      </div>
    )
  }

  return (
    <ul className="notes-list">
      {notes.map((note) =>
        note &&
        (!searchText ||
          note.title.toLowerCase().includes(searchText.toLowerCase())) ? (
          <li key={note.id}>
            <SidebarNote note={note} />
          </li>
        ) : null
      )}
    </ul>
  )
}

function excerpts(html, length) {
  const text = load(html)
    .text()
    .trim()
    .replace(/(\r\n|\r|\n|\s)+/g, ' ')

  let excerpt = ''
  if (length) {
    excerpt = text.split(' ').slice(0, length).join(' ')
  }
  if (excerpt.length < text.length) excerpt += '...'

  return excerpt
}

function SidebarNote({ note }) {
  const updatedAt = new Date(note.updated_at)
  const lastUpdatedAt = isToday(updatedAt)
    ? format(updatedAt, 'h:mm bb')
    : format(updatedAt, 'M/d/yy')
  const summary = excerpts(marked(note.body || ''), 20)

  return (
    <ClientSidebarNote
      id={note.id}
      title={note.title}
      expandedChildren={
        <p className="sidebar-note-excerpt">{summary || <i>(No content)</i>}</p>
      }
    >
      <header className="sidebar-note-header">
        <strong>{note.title}</strong>
        <small>{lastUpdatedAt}</small>
      </header>
    </ClientSidebarNote>
  )
}


================================================
FILE: components/note-preview.tsx
================================================
import marked from 'marked'
import sanitizeHtml from 'sanitize-html'

const allowedTags = sanitizeHtml.defaults.allowedTags.concat([
  'img',
  'h1',
  'h2',
  'h3'
])
const allowedAttributes = Object.assign(
  {},
  sanitizeHtml.defaults.allowedAttributes,
  {
    img: ['alt', 'src']
  }
)

export default function NotePreview({ children }) {
  return (
    <div className="note-preview">
      <div
        className="text-with-markdown"
        dangerouslySetInnerHTML={{
          __html: sanitizeHtml(marked(children || ''), {
            allowedTags,
            allowedAttributes
          })
        }}
      />
    </div>
  )
}


================================================
FILE: components/note-ui.tsx
================================================
import { format } from 'date-fns'
import NotePreview from 'components/note-preview'
import NoteEditor from 'components/note-editor'
import AuthButton from 'components/auth-button'
import { cookies } from 'next/headers'
import { getUser, userCookieKey } from 'libs/session'

export default async function NoteUI({ note, isEditing }) {
  const cookieStore = await cookies()
  const userCookie = cookieStore.get(userCookieKey)
  const user = getUser(userCookie?.value)
  const { id, title, body, updated_at, created_by: createdBy } = note
  const updatedAt = new Date(updated_at || 0)

  if (isEditing) {
    return <NoteEditor noteId={id} initialTitle={title} initialBody={body} />
  }

  return (
    <div className="note">
      <div className="note-header">
        <h1 className="note-title">{title}</h1>
        {createdBy ? (
          <div
            style={{
              flex: '1 0 100%',
              order: '-1',
              marginTop: 10
            }}
          >
            By{' '}
            <img
              src={`https://avatars.githubusercontent.com/${createdBy}?s=40`}
              alt="User Avatar"
              title={createdBy}
              className="avatar"
            />
            &nbsp;
            <a
              href={`https://github.com/${createdBy}`}
              target="_blank"
              rel="noopener noreferrer"
            >
              {createdBy}
            </a>
          </div>
        ) : null}
        <div className="note-menu" role="menubar">
          <small className="note-updated-at" role="status">
            Last updated on {format(updatedAt, "d MMM yyyy 'at' h:mm bb")}
          </small>
          {user === createdBy ? (
            <AuthButton noteId={id}>Edit</AuthButton>
          ) : (
            <div style={{ height: 30 }} />
          )}
        </div>
      </div>
      <NotePreview>{body}</NotePreview>
    </div>
  )
}


================================================
FILE: components/search.tsx
================================================
'use client'

import { usePathname, useRouter } from 'next/navigation'
import { useTransition } from 'react'

export default function SearchField() {
  const { replace } = useRouter()
  const pathname = usePathname()
  const [isPending, startTransition] = useTransition()

  function handleSearch(term: string) {
    const params = new URLSearchParams(window.location.search)
    if (term) {
      params.set('q', term)
    } else {
      params.delete('q')
    }

    startTransition(() => {
      replace(`${pathname}?${params.toString()}`)
    })
  }

  return (
    <div className="search" role="search">
      <label className="offscreen" htmlFor="sidebar-search-input">
        Search for a note by title
      </label>
      <input
        id="sidebar-search-input"
        type="text"
        name="search"
        placeholder="Search"
        spellCheck={false}
        onChange={(e) => handleSearch(e.target.value)}
      />
      <Spinner active={isPending} />
    </div>
  )
}

function Spinner({ active = true }) {
  return (
    <div
      className={['spinner', active && 'spinner--active'].join(' ')}
      role="progressbar"
      aria-busy={active ? 'true' : 'false'}
    />
  )
}


================================================
FILE: components/sidebar-note.tsx
================================================
'use client'

import { useState, useRef, useEffect, useTransition } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'

export default function SidebarNote({ id, title, children, expandedChildren }) {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const selectedId = pathname?.split('/')[1] || null
  const [isPending] = useTransition()
  const [isExpanded, setIsExpanded] = useState(false)
  const isActive = id === selectedId

  // Animate after title is edited
  const itemRef = useRef(null)
  const prevTitleRef = useRef(title)

  useEffect(() => {
    if (title !== prevTitleRef.current) {
      prevTitleRef.current = title
      // @ts-ignore
      itemRef.current.classList.add('flash')
    }
  }, [title])

  return (
    <div
      ref={itemRef}
      onAnimationEnd={() => {
        // @ts-ignore
        itemRef.current.classList.remove('flash')
      }}
      className={[
        'sidebar-note-list-item',
        isExpanded ? 'note-expanded' : ''
      ].join(' ')}
    >
      {children}
      <button
        className="sidebar-note-open"
        style={{
          backgroundColor: isPending
            ? 'var(--gray-80)'
            : isActive
              ? 'var(--tertiary-blue)'
              : undefined,
          border: isActive
            ? '1px solid var(--primary-border)'
            : '1px solid transparent'
        }}
        onClick={() => {
          // hide the sidebar
          const sidebarToggle = document.getElementById('sidebar-toggle')
          if (sidebarToggle) {
            // @ts-ignore
            sidebarToggle.checked = true
          }

          const url = new URL(`/note/${id}`, window.location.origin)
          searchParams.forEach((value, key) => {
            url.searchParams.append(key, value)
          })

          router.push(url.pathname + url.search)
        }}
      >
        Open note for preview
      </button>
      <button
        className="sidebar-note-toggle-expand"
        onClick={(e) => {
          e.stopPropagation()
          setIsExpanded(!isExpanded)
        }}
      >
        {isExpanded ? (
          <img
            src="/chevron-down.svg"
            width="10px"
            height="10px"
            alt="Collapse"
          />
        ) : (
          <img src="/chevron-up.svg" width="10px" height="10px" alt="Expand" />
        )}
      </button>
      {isExpanded && expandedChildren}
    </div>
  )
}


================================================
FILE: components/sidebar.tsx
================================================
'use client'

import React, { Suspense } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import SearchField from 'components/search'
import NoteList from 'components/note-list'
import NoteListSkeleton from 'components/note-list-skeleton'

type Note = {
  id: string
  created_by: string
  title: string
  body: string
  updated_at: number
}

export default function Sidebar({
  children,
  notes
}: {
  children: React.ReactNode
  notes: Note[]
}) {
  return (
    <>
      <input type="checkbox" className="sidebar-toggle" id="sidebar-toggle" />
      <section className="col sidebar">
        <Link href={'/'} className="link--unstyled">
          <section className="sidebar-header">
            <img
              className="logo"
              src="/logo.svg"
              width="22px"
              height="20px"
              alt=""
              role="presentation"
            />
            <strong>React Notes</strong>
          </section>
        </Link>
        <section className="sidebar-menu" role="menubar">
          <SearchField />
          {children}
        </section>
        <nav>
          <Suspense fallback={<NoteListSkeleton />}>
            <Notes notes={notes} />
          </Suspense>
        </nav>
      </section>
    </>
  )
}

function Notes({ notes }: { notes: Note[] }) {
  const searchParams = useSearchParams()
  const search = searchParams.get('q')

  return <NoteList notes={notes} searchText={search} />
}


================================================
FILE: libs/session.ts
================================================
export const userCookieKey = '_un'
export const cookieSep = '^)&_*($'

const iv = encode('encryptiv')
const password = process.env.SESSION_KEY

const pwUtf8 = encode(password)
const algo = { name: 'AES-GCM', iv }

function encode(value) {
  return new TextEncoder().encode(value)
}

function decode(value) {
  return new TextDecoder().decode(value)
}

function base64ToArrayBuffer(base64) {
  const binary = atob(base64)
  const len = binary.length
  const bytes = new Uint8Array(len)
  for (let i = 0; i < len; i++) {
    bytes[i] = binary.charCodeAt(i)
  }
  return bytes.buffer
}

function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer)
  // @ts-ignore
  const binary = String.fromCharCode(...bytes)
  return btoa(binary)
}

// Encrypt
export function createEncrypt() {
  return async function (data) {
    const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)
    const encryptKey = await crypto.subtle.importKey(
      'raw',
      pwHash,
      algo,
      false,
      ['encrypt']
    )
    const encrypted = await crypto.subtle.encrypt(
      algo,
      encryptKey,
      encode(data)
    )
    return arrayBufferToBase64(encrypted)
  }
}

// Decrypt
export function createDecrypt() {
  return async function decrypt(data) {
    const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)
    const buffer = base64ToArrayBuffer(data)
    const decryptKey = await crypto.subtle.importKey(
      'raw',
      pwHash,
      algo,
      false,
      ['decrypt']
    )
    const ptBuffer = await crypto.subtle.decrypt(algo, decryptKey, buffer)
    const decryptedText = decode(ptBuffer)
    return decryptedText
  }
}

export function getSession(userCookie) {
  const none = [null, null]
  const value = decodeURIComponent(userCookie)
  if (!value) return none
  const index = value.indexOf(cookieSep)
  if (index === -1) return none
  const user = value.slice(0, index)
  const session = value.slice(index + cookieSep.length)
  return [user, session]
}

export function getUser(userCookie) {
  return getSession(userCookie)[0]
}


================================================
FILE: middleware/api.ts
================================================
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getUser, userCookieKey } from 'libs/session'

export default function middleware(req: NextRequest) {
  const userCookie = req.cookies.get(userCookieKey)?.value
  const user = getUser(userCookie)

  if (req.method !== 'GET' && !user) {
    return NextResponse.json({ message: 'Unauthorized' }, { status: 403 })
  }

  return NextResponse.next()
}


================================================
FILE: middleware/auth.ts
================================================
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { userCookieKey, cookieSep, createEncrypt } from 'libs/session'

const CLIENT_ID = process.env.OAUTH_CLIENT_KEY
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET

export default async function middleware(req: NextRequest) {
  const { nextUrl } = req
  const { searchParams } = nextUrl
  const query = Object.fromEntries(searchParams)
  const encrypt = createEncrypt()
  const { code } = query

  // When there's no `code` param specified,
  // it's a GET from the client side.
  // We go with the login flow.
  if (!code) {
    // Login with GitHub
    const redirectUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&allow_signup=false`
    return NextResponse.redirect(redirectUrl)
  }

  let token = ''
  try {
    const data = await (
      await fetch('https://github.com/login/oauth/access_token', {
        method: 'POST',
        body: JSON.stringify({
          client_id: CLIENT_ID,
          client_secret: CLIENT_SECRET,
          code
        }),
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json'
        }
      })
    ).json()

    const accessToken = data.access_token

    // Let's also fetch the user info and store it in the session.
    if (accessToken) {
      const userInfo = await (
        await fetch('https://api.github.com/user', {
          method: 'GET',
          headers: {
            Authorization: `token ${accessToken}`,
            Accept: 'application/json'
          }
        })
      ).json()

      token = userInfo.login
    }
  } catch (err) {
    console.error(err)

    return NextResponse.json(
      { message: err.toString() },
      {
        status: 500
      }
    )
  }

  if (!token) {
    return NextResponse.json(
      { message: 'Github authorization failed' },
      {
        status: 400
      }
    )
  }

  const user = {
    name: token,
    encrypted: await encrypt(token)
  }

  const url = req.nextUrl.clone()
  url.searchParams.delete('code')
  url.pathname = '/'

  const res = NextResponse.redirect(url)

  res.cookies.set(
    userCookieKey,
    `${user.name}${cookieSep}${user.encrypted}; Secure; HttpOnly`
  )

  return res
}


================================================
FILE: middleware/edit.ts
================================================
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createDecrypt, getSession, userCookieKey } from 'libs/session'

export default async function middleware(req: NextRequest) {
  const decrypt = createDecrypt()
  const cookie = req.cookies.get(userCookieKey)?.value
  const [userCookie, sessionCookie] = getSession(cookie)

  let login: string | null = null
  let authErr = null

  if (sessionCookie && userCookie) {
    try {
      login = await decrypt(sessionCookie)
    } catch (e) {
      console.error(e)
      authErr = e
    }

    if (!authErr && login === userCookie) {
      return NextResponse.next()
    }
  }

  const url = req.nextUrl.clone()
  url.pathname = '/'
  return NextResponse.redirect(url)
}


================================================
FILE: middleware/logout.ts
================================================
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { userCookieKey } from 'libs/session'

export default async function middleware(req: NextRequest) {
  const url = req.nextUrl.clone()
  url.pathname = '/'
  const res = NextResponse.redirect(url.toString(), 302)

  res.cookies.set(
    userCookieKey,
    `deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`
  )

  return res
}


================================================
FILE: middleware.ts
================================================
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

import apiMiddleware from 'middleware/api'
import authMiddleware from 'middleware/auth'
import editMiddleware from 'middleware/edit'
import logoutMiddleware from 'middleware/logout'

function matchPathname(url, pathname) {
  return url.pathname.startsWith(pathname)
}

export async function middleware(req: NextRequest) {
  const url = req.nextUrl.clone()
  if (matchPathname(url, '/api')) {
    return apiMiddleware(req)
  }

  if (matchPathname(url, '/edit')) {
    return editMiddleware(req)
  }

  if (matchPathname(url, '/logout')) {
    return logoutMiddleware(req)
  }

  if (matchPathname(url, '/auth')) {
    return authMiddleware(req)
  }

  return NextResponse.next()
}


================================================
FILE: package.json
================================================
{
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "format": "prettier --write ."
  },
  "dependencies": {
    "@types/node": "^22.8.7",
    "@types/react": "npm:types-react@19.0.0-rc.1",
    "@vercel/kv": "^3.0.0",
    "cheerio": "1.0.0",
    "date-fns": "^4.1.0",
    "marked": "^1.2.9",
    "ms": "2.1.3",
    "next": "^15.0.5",
    "oauth": "^0.10.0",
    "postcss": "^8.4.47",
    "react": "19.0.0-rc-02c0e824-20241028",
    "react-dom": "19.0.0-rc-02c0e824-20241028",
    "sanitize-html": "^2.13.1",
    "typescript": "^5.6.3"
  },
  "prettier": {
    "arrowParens": "always",
    "singleQuote": true,
    "semi": false,
    "tabWidth": 2,
    "trailingComma": "none"
  },
  "pnpm": {
    "overrides": {
      "@types/react": "npm:types-react@19.0.0-rc.1"
    }
  }
}


================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "baseUrl": ".",
    "plugins": [
      {
        "name": "next"
      }
    ],
    "incremental": true,
    "module": "esnext",
    "strictNullChecks": true,
    "target": "ES2017"
  },
  "include": [
    "next-env.d.ts",
    ".next/types/**/*.ts",
    "**/*.ts",
    "**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}
Download .txt
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
Download .txt
SYMBOL INDEX (41 symbols across 23 files)

FILE: app/actions.ts
  function saveNote (line 9) | async function saveNote(
  function deleteNote (line 36) | async function deleteNote(noteId: string) {

FILE: app/layout.tsx
  type Note (line 24) | type Note = {
  function RootLayout (line 32) | async function RootLayout({

FILE: app/note/[id]/loading.tsx
  function NoteSkeleton (line 1) | function NoteSkeleton() {

FILE: app/note/[id]/page.tsx
  function Page (line 10) | async function Page(props: { params: Promise<{ id: string }> }) {

FILE: app/note/edit/[id]/page.tsx
  type Note (line 12) | type Note = {
  function EditPage (line 17) | async function EditPage(props: { params: Promise<{ id: string }> }) {

FILE: app/note/edit/loading.tsx
  function EditSkeleton (line 1) | function EditSkeleton() {

FILE: app/note/edit/page.tsx
  function EditPage (line 9) | async function EditPage() {

FILE: app/page.tsx
  function Page (line 1) | async function Page() {

FILE: components/auth-button.tsx
  function AuthButton (line 5) | async function AuthButton({

FILE: components/note-editor.tsx
  function NoteEditor (line 8) | function NoteEditor({

FILE: components/note-list-skeleton.tsx
  function NoteListSkeleton (line 1) | function NoteListSkeleton() {

FILE: components/note-list.tsx
  function NoteList (line 9) | function NoteList({ notes, searchText }) {
  function excerpts (line 35) | function excerpts(html, length) {
  function SidebarNote (line 50) | function SidebarNote({ note }) {

FILE: components/note-preview.tsx
  function NotePreview (line 18) | function NotePreview({ children }) {

FILE: components/note-ui.tsx
  function NoteUI (line 8) | async function NoteUI({ note, isEditing }) {

FILE: components/search.tsx
  function SearchField (line 6) | function SearchField() {
  function Spinner (line 42) | function Spinner({ active = true }) {

FILE: components/sidebar-note.tsx
  function SidebarNote (line 6) | function SidebarNote({ id, title, children, expandedChildren }) {

FILE: components/sidebar.tsx
  type Note (line 10) | type Note = {
  function Sidebar (line 18) | function Sidebar({
  function Notes (line 56) | function Notes({ notes }: { notes: Note[] }) {

FILE: libs/session.ts
  function encode (line 10) | function encode(value) {
  function decode (line 14) | function decode(value) {
  function base64ToArrayBuffer (line 18) | function base64ToArrayBuffer(base64) {
  function arrayBufferToBase64 (line 28) | function arrayBufferToBase64(buffer) {
  function createEncrypt (line 36) | function createEncrypt() {
  function createDecrypt (line 56) | function createDecrypt() {
  function getSession (line 73) | function getSession(userCookie) {
  function getUser (line 84) | function getUser(userCookie) {

FILE: middleware.ts
  function matchPathname (line 9) | function matchPathname(url, pathname) {
  function middleware (line 13) | async function middleware(req: NextRequest) {

FILE: middleware/api.ts
  function middleware (line 5) | function middleware(req: NextRequest) {

FILE: middleware/auth.ts
  constant CLIENT_ID (line 5) | const CLIENT_ID = process.env.OAUTH_CLIENT_KEY
  constant CLIENT_SECRET (line 6) | const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET
  function middleware (line 8) | async function middleware(req: NextRequest) {

FILE: middleware/edit.ts
  function middleware (line 5) | async function middleware(req: NextRequest) {

FILE: middleware/logout.ts
  function middleware (line 5) | async function middleware(req: NextRequest) {
Condensed preview — 30 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (53K chars).
[
  {
    "path": ".gitignore",
    "chars": 393,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": "LICENSE",
    "chars": 1063,
    "preview": "MIT License\n\nCopyright (c) 2024 Vercel\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
  },
  {
    "path": "README.md",
    "chars": 1710,
    "preview": "# Next.js App Router + Server Components Notes Demo\n\n> Try the demo live here: [**next-rsc-notes.vercel.app**](https://n"
  },
  {
    "path": "app/actions.ts",
    "chars": 875,
    "preview": "'use server'\n\nimport { kv } from './kv-client'\nimport { getUser, userCookieKey } from 'libs/session'\nimport { cookies } "
  },
  {
    "path": "app/kv-client.ts",
    "chars": 274,
    "preview": "import { kv as vercelKv } from '@vercel/kv'\n\nconst hasKvConfig =\n  process.env.KV_REST_API_URL && process.env.KV_REST_AP"
  },
  {
    "path": "app/layout.tsx",
    "chars": 1690,
    "preview": "import './style.css'\n\nimport React from 'react'\nimport { kv } from './kv-client'\nimport Sidebar from 'components/sidebar"
  },
  {
    "path": "app/note/[id]/loading.tsx",
    "chars": 905,
    "preview": "export default function NoteSkeleton() {\n  return (\n    <div\n      className=\"note skeleton-container\"\n      role=\"progr"
  },
  {
    "path": "app/note/[id]/page.tsx",
    "chars": 584,
    "preview": "import { kv } from '../../kv-client'\nimport NoteUI from 'components/note-ui'\n\nexport const metadata = {\n  robots: {\n    "
  },
  {
    "path": "app/note/edit/[id]/page.tsx",
    "chars": 932,
    "preview": "import { kv } from '../../../kv-client'\nimport { cookies } from 'next/headers'\nimport { getUser, userCookieKey } from 'l"
  },
  {
    "path": "app/note/edit/loading.tsx",
    "chars": 1354,
    "preview": "export default function EditSkeleton() {\n  return (\n    <div\n      className=\"note-editor skeleton-container\"\n      role"
  },
  {
    "path": "app/note/edit/page.tsx",
    "chars": 269,
    "preview": "import NoteUI from 'components/note-ui'\n\nexport const metadata = {\n  robots: {\n    index: false\n  }\n}\n\nexport default as"
  },
  {
    "path": "app/page.tsx",
    "chars": 223,
    "preview": "export default async function Page() {\n  return (\n    <div className=\"note--empty-state\">\n      <span className=\"note-te"
  },
  {
    "path": "app/style.css",
    "chars": 16447,
    "preview": "/* -------------------------------- CSSRESET --------------------------------*/\n/* CSS Reset adapted from https://dev.to"
  },
  {
    "path": "components/auth-button.tsx",
    "chars": 1273,
    "preview": "import Link from 'next/link'\nimport { cookies } from 'next/headers'\nimport { getUser, userCookieKey } from 'libs/session"
  },
  {
    "path": "components/note-editor.tsx",
    "chars": 2409,
    "preview": "'use client'\n\nimport { useState } from 'react'\nimport NotePreview from './note-preview'\nimport { useFormStatus } from 'r"
  },
  {
    "path": "components/note-list-skeleton.tsx",
    "chars": 655,
    "preview": "export default function NoteListSkeleton() {\n  return (\n    <div>\n      <ul className=\"notes-list skeleton-container\">\n "
  },
  {
    "path": "components/note-list.tsx",
    "chars": 1697,
    "preview": "'use client'\n\nimport React from 'react'\nimport { format, isToday } from 'date-fns'\nimport marked from 'marked'\nimport Cl"
  },
  {
    "path": "components/note-preview.tsx",
    "chars": 638,
    "preview": "import marked from 'marked'\nimport sanitizeHtml from 'sanitize-html'\n\nconst allowedTags = sanitizeHtml.defaults.allowedT"
  },
  {
    "path": "components/note-ui.tsx",
    "chars": 1908,
    "preview": "import { format } from 'date-fns'\nimport NotePreview from 'components/note-preview'\nimport NoteEditor from 'components/n"
  },
  {
    "path": "components/search.tsx",
    "chars": 1199,
    "preview": "'use client'\n\nimport { usePathname, useRouter } from 'next/navigation'\nimport { useTransition } from 'react'\n\nexport def"
  },
  {
    "path": "components/sidebar-note.tsx",
    "chars": 2498,
    "preview": "'use client'\n\nimport { useState, useRef, useEffect, useTransition } from 'react'\nimport { useRouter, usePathname, useSea"
  },
  {
    "path": "components/sidebar.tsx",
    "chars": 1494,
    "preview": "'use client'\n\nimport React, { Suspense } from 'react'\nimport Link from 'next/link'\nimport { useSearchParams } from 'next"
  },
  {
    "path": "libs/session.ts",
    "chars": 2064,
    "preview": "export const userCookieKey = '_un'\nexport const cookieSep = '^)&_*($'\n\nconst iv = encode('encryptiv')\nconst password = p"
  },
  {
    "path": "middleware/api.ts",
    "chars": 445,
    "preview": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\nimport { getUser, userCookieKe"
  },
  {
    "path": "middleware/auth.ts",
    "chars": 2271,
    "preview": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\nimport { userCookieKey, cookie"
  },
  {
    "path": "middleware/edit.ts",
    "chars": 764,
    "preview": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\nimport { createDecrypt, getSes"
  },
  {
    "path": "middleware/logout.ts",
    "chars": 429,
    "preview": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\nimport { userCookieKey } from "
  },
  {
    "path": "middleware.ts",
    "chars": 772,
    "preview": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport apiMiddleware from 'mi"
  },
  {
    "path": "package.json",
    "chars": 852,
    "preview": "{\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev --turbopack\",\n    \"build\": \"next build\",\n    \"start\": \"next sta"
  },
  {
    "path": "tsconfig.json",
    "chars": 701,
    "preview": "{\n  \"compilerOptions\": {\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    "
  }
]

About this extraction

This page contains the full source code of the vercel/next-server-components GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 30 files (47.6 KB), approximately 14.1k tokens, and a symbol index with 41 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!