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