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:
[](<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"
/>
<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"
]
}
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
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.