├── .gitignore
├── .prettierignore
├── LICENSE
├── README.md
├── components
├── App.server.js
├── AuthButton.server.js
├── Cache.client.js
├── EditButton.client.js
├── LocationContext.client.js
├── Note.server.js
├── NoteEditor.client.js
├── NoteList.server.js
├── NoteListSkeleton.js
├── NotePreview.js
├── NoteSkeleton.js
├── Root.client.js
├── SearchField.client.js
├── SidebarNote.client.js
├── SidebarNote.js
├── Spinner.js
└── TextWithMarkdown.js
├── libs
├── initSupabase.js
├── send-res-with-module-map.js
└── send-res.js
├── next.config.js
├── package.json
├── pages
├── _app.js
├── api
│ ├── auth.js
│ ├── index.js
│ └── notes
│ │ ├── [id].js
│ │ └── index.js
└── index.js
├── public
├── checkmark.svg
├── chevron-down.svg
├── chevron-up.svg
├── cross.svg
├── favicon.ico
├── logo.svg
├── menu.svg
├── og.png
└── x.svg
├── schema.sql
├── scripts
├── build.js
└── client-react-loader.js
├── style.css
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .next
2 | node_modules
3 | yarn-error.log
4 | .DS_Store
5 | .vscode
6 |
7 | .vercel
8 |
9 | .env
10 |
11 | libs/react-client-manifest.json
12 | libs/send-res.build.js
13 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .next
2 | node_modules
3 | yarn-error.log
4 | .DS_Store
5 | .vscode
6 |
7 | .vercel
8 |
9 | .env
10 |
11 | libs/react-client-manifest.json
12 | libs/send-res.build.js
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Vercel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ⚠️ THIS REPO IS OUTDATED AND ARCHIVED
2 |
3 | For an up to date example of Next.js 13 Server Components, please refer to https://github.com/supabase/auth-helpers/tree/main/examples/nextjs-server-components
4 |
5 |
6 |
7 | # React Server Components in Next.js
8 |
9 | Experimental app of React Server Components with Next.js, based on [React Server Components Demo](https://github.com/reactjs/server-components-demo).
10 | **It's not ready for adoption. Use this in your projects at your own risk.**
11 |
12 | ## Development
13 |
14 | ### Prepare
15 |
16 | You need these environment variables to run this app (you can create a `.env` file):
17 |
18 | ```
19 | ENDPOINT='http://localhost:3000' # need to be absolute url to run in prod/local
20 | NEXT_PUBLIC_ENDPOINT='http://localhost:3000' # same as above
21 | NEXT_PUBLIC_SUPABASE_URL='https://XXXX.supabase.co' # Supabase API URL: https://app.supabase.io/project/{YOUR_PROJECT}/settings/api
22 | NEXT_PUBLIC_SUPABASE_ANON_KEY='anon.key.xxx.yyy' # Supabase anon Key: https://app.supabase.io/project/{YOUR_PROJECT}/settings/api
23 | ```
24 |
25 | ### Start
26 |
27 | 1. `yarn install` (this will trigger the postinstall command)
28 | 2. `yarn dev`
29 |
30 | Go to `localhost:3000` to view the application.
31 |
32 | ### Deploy
33 |
34 | [](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext-server-components&env=ENDPOINT,NEXT_PUBLIC_ENDPOINT,NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY&project-name=next-server-components&repo-name=next-server-components&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-server-components.supabase.vercel.app&demo-image=https%3A%2F%2Fnext-server-components.supabase.vercel.app%2Fog.png)
35 |
36 | ## Caveats
37 |
38 | - Only `.js` extension is supported.
39 | - Client / server components should be under the `components` directory.
40 | - Some React Hooks are not supported in server components, such as `useContext`.
41 | - You have to manually import `React` in your server components.
42 |
43 | ## How does it work?
44 |
45 | Application APIs:
46 |
47 | - `GET, POST /api/notes` (Get all notes, Create a new note)
48 | - `GET, PUT, DELETE /api/notes/` (Action for a specific note)
49 |
50 | React Server Components API (`pages/api/index.js`):
51 |
52 | - `GET /api` (render application and return the serialized components)
53 |
54 | Note: Some of the application APIs (`POST`, `PUT`, `DELETE`) will render and return the serialized components as well. The render logic is handled by `libs/send-res.js`.
55 |
56 | `libs/send-res.js` accepts the props (from `req.query.location` and `req.session.login`) that needs to be rendered by `components/App.server.js` (the component tree entry). Then, it renders the tree and streams it to `res` using:
57 |
58 | ```js
59 | pipeToNodeWritable(React.createElement(App, props), res, moduleMap)
60 | ```
61 |
62 | `moduleMap` is generated by client-side Webpack (through Next.js). It traverses both `.server.js` and `.client.js` and generates the full module map from the `react-server-dom-webpack/plugin` Webpack plugin (see `next.config.js`).
63 | Then, we use a custom plugin to copy it to `libs/react-client-manifest.json` and include it from the lambdas (see `libs/send-res-with-module-map.js`).
64 |
65 | `App` is a special build of `components/App.server.js`, which removes all the client components (marked as special modules) because they're not accessible from the server. We bundled it together with `libs/send-res.js` with another Webpack loader into `libs/send-res.build.js`. The Webpack script and loader are under `scripts/`. It should run whenever a server component is updated.
66 |
67 | Finally, everything related to OAuth is inside `pages/api/auth.js`. It's a cookie-based session using GitHub for login.
68 |
--------------------------------------------------------------------------------
/components/App.server.js:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react'
2 |
3 | import SearchField from './SearchField.client'
4 |
5 | import Note from './Note.server'
6 | import NoteList from './NoteList.server'
7 | import AuthButton from './AuthButton.server'
8 |
9 | import NoteSkeleton from './NoteSkeleton'
10 | import NoteListSkeleton from './NoteListSkeleton'
11 |
12 | export default function App({ selectedId, isEditing, searchText, login }) {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
27 | React Notes
28 |
29 |
30 |
31 |
32 | Add
33 |
34 |
35 |
36 | }>
37 |
38 |
39 |
40 |
41 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/components/AuthButton.server.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import EditButton from './EditButton.client'
4 |
5 | export default function AuthButton({ children, login, ...props }) {
6 | if (login) {
7 | return (
8 |
9 | {children}
10 | {login.user_metadata.avatar_url && (
11 |
17 | )}
18 |
19 | )
20 | }
21 |
22 | return (
23 |
24 | Login to {children}
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/components/Cache.client.js:
--------------------------------------------------------------------------------
1 | import { createFromFetch } from 'react-server-dom-webpack'
2 |
3 | const endpoint = process.env.NEXT_PUBLIC_ENDPOINT
4 |
5 | const cache = new Map()
6 |
7 | export function useRefresh() {
8 | return function refresh(key, seededResponse) {
9 | cache.clear()
10 | cache.set(key, seededResponse)
11 | }
12 | }
13 |
14 | export function useServerResponse(location) {
15 | const key = JSON.stringify(location)
16 | let response = cache.get(key)
17 | if (response) {
18 | return response
19 | }
20 | response = createFromFetch(
21 | fetch(endpoint + '/api?location=' + encodeURIComponent(key))
22 | )
23 | cache.set(key, response)
24 | return response
25 | }
26 |
--------------------------------------------------------------------------------
/components/EditButton.client.js:
--------------------------------------------------------------------------------
1 | import React, { unstable_useTransition, useState } from 'react'
2 | import { Auth, Modal, Button } from '@supabase/ui'
3 |
4 | import { useLocation } from './LocationContext.client'
5 | import { supabase } from '../libs/initSupabase'
6 |
7 | export default function EditButton({
8 | login,
9 | noteId,
10 | disabled,
11 | title,
12 | children,
13 | }) {
14 | const [, setLocation] = useLocation()
15 | const [startTransition, isPending] = unstable_useTransition()
16 | const isDraft = noteId == null
17 | const [modalVisible, setModalVisible] = useState(false)
18 |
19 | return (
20 | <>
21 | setModalVisible(false)}>Close,
27 | ]}
28 | >
29 |
35 |
36 | {
44 | if (login) {
45 | // login needed
46 | setModalVisible(true)
47 | return
48 | }
49 | if (isDraft) {
50 | // hide the sidebar
51 | const sidebarToggle = document.getElementById('sidebar-toggle')
52 | if (sidebarToggle) {
53 | sidebarToggle.checked = true
54 | }
55 | }
56 | startTransition(() => {
57 | setLocation(loc => ({
58 | selectedId: noteId,
59 | isEditing: true,
60 | searchText: loc.searchText,
61 | }))
62 | })
63 | }}
64 | role="menuitem"
65 | >
66 | {children}
67 |
68 | >
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/components/LocationContext.client.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react'
2 |
3 | export const LocationContext = createContext()
4 | export function useLocation() {
5 | return useContext(LocationContext)
6 | }
7 |
--------------------------------------------------------------------------------
/components/Note.server.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { fetch } from 'react-fetch'
3 | import { format } from 'date-fns'
4 |
5 | import NotePreview from './NotePreview'
6 | import NoteEditor from './NoteEditor.client'
7 | import AuthButton from './AuthButton.server'
8 |
9 | const endpoint = process.env.ENDPOINT
10 |
11 | export default function Note({ selectedId, isEditing, login }) {
12 | const note =
13 | selectedId != null
14 | ? fetch(`${endpoint}/api/notes/${selectedId}`).json()
15 | : null
16 |
17 | if (note === null) {
18 | if (isEditing) {
19 | return
20 | } else {
21 | return (
22 |
23 |
24 | Click a note on the left to view something!
25 |
26 |
27 | )
28 | }
29 | }
30 |
31 | const { id, title, body, updated_at, created_by } = note
32 | const updatedAt = new Date(updated_at)
33 |
34 | if (isEditing) {
35 | return
36 | } else {
37 | return (
38 |
39 |
40 |
{title}
41 |
42 |
43 | Last updated on {format(updatedAt, "d MMM yyyy 'at' h:mm bb")}
44 |
45 | {login && login.id === created_by ? (
46 |
47 | Edit
48 |
49 | ) : (
50 |
51 | )}
52 |
53 |
54 |
55 |
56 | )
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/components/NoteEditor.client.js:
--------------------------------------------------------------------------------
1 | import React, { useState, unstable_useTransition } from 'react'
2 | import { createFromReadableStream } from 'react-server-dom-webpack'
3 |
4 | import NotePreview from './NotePreview'
5 | import { useRefresh } from './Cache.client'
6 | import { useLocation } from './LocationContext.client'
7 |
8 | export default function NoteEditor({ noteId, initialTitle, initialBody }) {
9 | const refresh = useRefresh()
10 | const [title, setTitle] = useState(initialTitle)
11 | const [body, setBody] = useState(initialBody)
12 | const [location, setLocation] = useLocation()
13 | const [startNavigating, isNavigating] = unstable_useTransition()
14 | const [isSaving, saveNote] = useMutation({
15 | endpoint: noteId !== null ? `/api/notes/${noteId}` : `/api/notes`,
16 | method: noteId !== null ? 'PUT' : 'POST',
17 | })
18 | const [isDeleting, deleteNote] = useMutation({
19 | endpoint: `/api/notes/${noteId}`,
20 | method: 'DELETE',
21 | })
22 |
23 | async function handleSave() {
24 | const payload = { title, body }
25 | const requestedLocation = {
26 | selectedId: noteId,
27 | isEditing: false,
28 | searchText: location.searchText,
29 | }
30 | const response = await saveNote(payload, requestedLocation)
31 | navigate(response)
32 | }
33 |
34 | async function handleDelete() {
35 | const payload = {}
36 | const requestedLocation = {
37 | selectedId: null,
38 | isEditing: false,
39 | searchText: location.searchText,
40 | }
41 | const response = await deleteNote(payload, requestedLocation)
42 | navigate(response)
43 | }
44 |
45 | async function navigate(response) {
46 | const cacheKey = response.headers.get('X-Location')
47 | const nextLocation = JSON.parse(cacheKey)
48 | const seededResponse = createFromReadableStream(response.body)
49 | startNavigating(() => {
50 | refresh(cacheKey, seededResponse)
51 | setLocation(nextLocation)
52 | })
53 | }
54 |
55 | const isDraft = noteId === null
56 | return (
57 |
58 |
83 |
84 |
85 |
handleSave()}
89 | role="menuitem"
90 | >
91 |
98 | Done
99 |
100 | {!isDraft && (
101 |
handleDelete()}
105 | role="menuitem"
106 | >
107 |
114 | Delete
115 |
116 | )}
117 |
118 |
119 | Preview
120 |
121 |
{title}
122 |
123 |
124 |
125 | )
126 | }
127 |
128 | function useMutation({ endpoint, method }) {
129 | const [isSaving, setIsSaving] = useState(false)
130 | const [didError, setDidError] = useState(false)
131 | const [error, setError] = useState(null)
132 | if (didError) {
133 | // Let the nearest error boundary handle errors while saving.
134 | throw error
135 | }
136 |
137 | async function performMutation(payload, requestedLocation) {
138 | setIsSaving(true)
139 | try {
140 | const response = await fetch(
141 | `${endpoint}?location=${encodeURIComponent(
142 | JSON.stringify(requestedLocation)
143 | )}`,
144 | {
145 | method,
146 | body: JSON.stringify(payload),
147 | headers: {
148 | 'Content-Type': 'application/json',
149 | },
150 | }
151 | )
152 | if (!response.ok) {
153 | throw new Error(await response.text())
154 | }
155 | return response
156 | } catch (e) {
157 | setDidError(true)
158 | setError(e)
159 | } finally {
160 | setIsSaving(false)
161 | }
162 | }
163 |
164 | return [isSaving, performMutation]
165 | }
166 |
--------------------------------------------------------------------------------
/components/NoteList.server.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { fetch } from 'react-fetch'
3 |
4 | import SidebarNote from './SidebarNote'
5 |
6 | const endpoint = process.env.ENDPOINT
7 |
8 | export default function NoteList({ searchText }) {
9 | const notes = fetch(endpoint + '/api/notes').json()
10 |
11 | return notes.length > 0 ? (
12 |
13 | {notes.map(note =>
14 | note &&
15 | (!searchText ||
16 | note.title.toLowerCase().includes(searchText.toLowerCase())) ? (
17 |
18 |
19 |
20 | ) : null
21 | )}
22 |
23 | ) : (
24 |
25 | {searchText
26 | ? `Couldn't find any notes titled "${searchText}".`
27 | : 'No notes created yet!'}{' '}
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/components/NoteListSkeleton.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function NoteListSkeleton() {
4 | return (
5 |
6 |
7 |
8 |
12 |
13 |
14 |
18 |
19 |
20 |
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/components/NotePreview.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import TextWithMarkdown from './TextWithMarkdown'
4 |
5 | export default function NotePreview({ body }) {
6 | return (
7 |
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/components/NoteSkeleton.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | function NoteEditorSkeleton() {
4 | return (
5 |
10 |
14 |
15 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | )
39 | }
40 |
41 | function NotePreviewSkeleton() {
42 | return (
43 |
48 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | )
67 | }
68 |
69 | export default function NoteSkeleton({ isEditing }) {
70 | return isEditing ? :
71 | }
72 |
--------------------------------------------------------------------------------
/components/Root.client.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, Suspense } from 'react'
2 | import { ErrorBoundary } from 'react-error-boundary'
3 |
4 | import { useServerResponse } from './Cache.client'
5 | import { LocationContext } from './LocationContext.client'
6 |
7 | import { supabase } from '../libs/initSupabase'
8 |
9 | export default function Root() {
10 | useEffect(() => {
11 | const { data: authListener } = supabase.auth.onAuthStateChange(
12 | async (event, session) => {
13 | await fetch('/api/auth', {
14 | method: 'POST',
15 | headers: new Headers({ 'Content-Type': 'application/json' }),
16 | credentials: 'same-origin',
17 | body: JSON.stringify({ event, session }),
18 | }).then(res => res.json())
19 | if (event === 'SIGNED_IN') window.location.reload()
20 | }
21 | )
22 |
23 | return () => {
24 | authListener.unsubscribe()
25 | }
26 | }, [])
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 | )
35 | }
36 |
37 | function Content() {
38 | const [location, setLocation] = useState({
39 | selectedId: null,
40 | isEditing: false,
41 | searchText: '',
42 | })
43 | const response = useServerResponse(location)
44 | const root = response.readRoot()
45 |
46 | return (
47 |
48 | {root}
49 |
50 | )
51 | }
52 |
53 | function Error({ error }) {
54 | return (
55 |
56 |
Application Error
57 |
{error.stack}
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/components/SearchField.client.js:
--------------------------------------------------------------------------------
1 | import React, { useState, unstable_useTransition } from 'react'
2 |
3 | import { useLocation } from './LocationContext.client'
4 | import Spinner from './Spinner'
5 |
6 | export default function SearchField() {
7 | const [text, setText] = useState('')
8 | const [startSearching, isSearching] = unstable_useTransition(false)
9 | const [, setLocation] = useLocation()
10 | return (
11 | e.preventDefault()}>
12 |
13 | Search for a note by title
14 |
15 | {
20 | const newText = e.target.value
21 | setText(newText)
22 | startSearching(() => {
23 | setLocation(loc => ({
24 | ...loc,
25 | searchText: newText,
26 | }))
27 | })
28 | }}
29 | />
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/components/SidebarNote.client.js:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect, unstable_useTransition } from 'react'
2 |
3 | import { useLocation } from './LocationContext.client'
4 |
5 | export default function SidebarNote({ id, title, children, expandedChildren }) {
6 | const [location, setLocation] = useLocation()
7 | const [startTransition, isPending] = unstable_useTransition()
8 | const [isExpanded, setIsExpanded] = useState(false)
9 | const isActive = id === location.selectedId
10 |
11 | // Animate after title is edited.
12 | const itemRef = useRef(null)
13 | const prevTitleRef = useRef(title)
14 | useEffect(() => {
15 | if (title !== prevTitleRef.current) {
16 | prevTitleRef.current = title
17 | itemRef.current.classList.add('flash')
18 | }
19 | }, [title])
20 |
21 | return (
22 | {
25 | itemRef.current.classList.remove('flash')
26 | }}
27 | className={[
28 | 'sidebar-note-list-item',
29 | isExpanded ? 'note-expanded' : '',
30 | ].join(' ')}
31 | >
32 | {children}
33 |
{
46 | startTransition(() => {
47 | // hide the sidebar
48 | const sidebarToggle = document.getElementById('sidebar-toggle')
49 | if (sidebarToggle) {
50 | sidebarToggle.checked = true
51 | }
52 | setLocation(loc => ({
53 | selectedId: id,
54 | isEditing: false,
55 | searchText: loc.searchText,
56 | }))
57 | })
58 | }}
59 | >
60 | Open note for preview
61 |
62 |
{
65 | e.stopPropagation()
66 | setIsExpanded(!isExpanded)
67 | }}
68 | >
69 | {isExpanded ? (
70 |
76 | ) : (
77 |
78 | )}
79 |
80 | {isExpanded && expandedChildren}
81 |
82 | )
83 | }
84 |
--------------------------------------------------------------------------------
/components/SidebarNote.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Facebook, Inc. and its affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import React from 'react'
10 | import { format, isToday } from 'date-fns'
11 | import excerpts from 'excerpts'
12 | import marked from 'marked'
13 |
14 | import ClientSidebarNote from './SidebarNote.client'
15 |
16 | export default function SidebarNote({ note }) {
17 | const updatedAt = new Date(note.updated_at)
18 | const lastUpdatedAt = isToday(updatedAt)
19 | ? format(updatedAt, 'h:mm bb')
20 | : format(updatedAt, 'M/d/yy')
21 | const summary = excerpts(marked(note.body || ''), { words: 20 })
22 | return (
23 | {summary || (No content) }
28 | }
29 | >
30 |
31 | {note.title}
32 | {lastUpdatedAt}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/components/Spinner.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Facebook, Inc. and its affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import React from 'react'
10 |
11 | export default function Spinner({ active = true }) {
12 | return (
13 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/components/TextWithMarkdown.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Facebook, Inc. and its affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import React from 'react'
10 | import marked from 'marked'
11 | import sanitizeHtml from 'sanitize-html'
12 |
13 | const allowedTags = sanitizeHtml.defaults.allowedTags.concat([
14 | 'img',
15 | 'h1',
16 | 'h2',
17 | 'h3',
18 | ])
19 | const allowedAttributes = Object.assign(
20 | {},
21 | sanitizeHtml.defaults.allowedAttributes,
22 | {
23 | img: ['alt', 'src'],
24 | }
25 | )
26 |
27 | export default function TextWithMarkdown({ text }) {
28 | return (
29 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/libs/initSupabase.js:
--------------------------------------------------------------------------------
1 | import { createClient } from '@supabase/supabase-js'
2 |
3 | const supabase = createClient(
4 | process.env.NEXT_PUBLIC_SUPABASE_URL,
5 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
6 | )
7 |
8 | export { supabase }
9 |
--------------------------------------------------------------------------------
/libs/send-res-with-module-map.js:
--------------------------------------------------------------------------------
1 | import sendRes from './send-res.build'
2 |
3 | const moduleMap = require('./react-client-manifest.json')
4 |
5 | export default (...args) => sendRes(...args, moduleMap)
6 |
--------------------------------------------------------------------------------
/libs/send-res.js:
--------------------------------------------------------------------------------
1 | import { pipeToNodeWritable } from 'react-server-dom-webpack/writer.node.server'
2 |
3 | import React from 'react'
4 | import App from '../components/App.server'
5 | import { supabase } from '../libs/initSupabase'
6 |
7 | let moduleMap
8 | const componentRegex = /components\/.+\.js/
9 |
10 | async function renderReactTree(props, res, moduleMap_) {
11 | if (!moduleMap) {
12 | const manifest = {}
13 |
14 | // We need to remap the filepaths in the manifest
15 | // because they have different working directory
16 | // inside the function.
17 | for (let key in moduleMap_) {
18 | const componentPath = key.match(componentRegex)[0]
19 | manifest[componentPath] = moduleMap_[key]
20 | }
21 | moduleMap = new Proxy(manifest, {
22 | get: function (target, prop) {
23 | const componentPath = prop.match(componentRegex)[0]
24 | return target[componentPath]
25 | },
26 | })
27 | }
28 |
29 | pipeToNodeWritable(React.createElement(App, props), res, moduleMap)
30 | }
31 |
32 | module.exports = async (req, res, redirectToId, moduleMap) => {
33 | console.time('react render')
34 | res.on('close', () => console.timeEnd('react render'))
35 |
36 | let location
37 | try {
38 | location = JSON.parse(req.query.location)
39 | } catch (err) {
40 | return res.send('Missing parameter, skipped.')
41 | }
42 |
43 | if (redirectToId) {
44 | location.selectedId = redirectToId
45 | }
46 | res.setHeader('X-Location', JSON.stringify(location))
47 |
48 | const { user } = await supabase.auth.api.getUserByCookie(req)
49 |
50 | renderReactTree(
51 | {
52 | selectedId: location.selectedId,
53 | isEditing: location.isEditing,
54 | searchText: location.searchText,
55 | login: user || null,
56 | },
57 | res,
58 | moduleMap
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin')
2 | const fs = require('fs')
3 |
4 | let manifest
5 | class CopyReactClientManifest {
6 | apply(compiler) {
7 | compiler.hooks.emit.tapAsync(
8 | 'CopyReactClientManifest',
9 | (compilation, callback) => {
10 | const asset = compilation.assets['react-client-manifest.json']
11 | const content = asset.source()
12 | // there might be multiple passes (?)
13 | // we keep the larger manifest
14 | if (manifest && manifest.length > content.length) {
15 | callback()
16 | return
17 | }
18 | manifest = content
19 | fs.writeFile('./libs/react-client-manifest.json', content, callback)
20 | }
21 | )
22 | }
23 | }
24 |
25 | module.exports = {
26 | experimental: {
27 | reactMode: 'concurrent',
28 | },
29 | api: {
30 | bodyParser: false,
31 | },
32 | webpack: config => {
33 | config.plugins.push(new ReactServerWebpackPlugin({ isServer: false }))
34 | config.plugins.push(new CopyReactClientManifest())
35 | return config
36 | },
37 | }
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server-components-next",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "scripts": {
7 | "postinstall": "node scripts/build",
8 | "refresh": "node scripts/build",
9 | "dev": "next",
10 | "build": "next build",
11 | "start": "next start",
12 | "format": "prettier --write ."
13 | },
14 | "dependencies": {
15 | "@babel/core": "^7.12.10",
16 | "@babel/plugin-transform-modules-commonjs": "^7.12.1",
17 | "@babel/preset-react": "^7.12.10",
18 | "@babel/register": "^7.12.10",
19 | "@supabase/supabase-js": "^1.1.3",
20 | "@supabase/ui": "^0.7.0",
21 | "babel-loader": "^8.2.2",
22 | "date-fns": "^2.16.1",
23 | "excerpts": "^0.0.3",
24 | "marked": "^1.2.7",
25 | "next": "^10.0.4",
26 | "react": "0.0.0-experimental-3310209d0",
27 | "react-dom": "0.0.0-experimental-3310209d0",
28 | "react-error-boundary": "^3.1.0",
29 | "react-fetch": "0.0.0-experimental-3310209d0",
30 | "react-server-dom-webpack": "0.0.0-experimental-3310209d0",
31 | "sanitize-html": "^2.3.0",
32 | "webpack": "4.44.1"
33 | },
34 | "devDependencies": {
35 | "prettier": "2.2.1"
36 | },
37 | "prettier": {
38 | "semi": false,
39 | "singleQuote": true,
40 | "jsxBracketSameLine": false,
41 | "arrowParens": "avoid"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import '../style.css'
3 |
4 | export default function MyApp({ Component, pageProps }) {
5 | return (
6 | <>
7 |
8 | React Server Components (Experimental Demo)
9 |
10 |
14 |
18 |
19 |
23 |
27 |
31 |
35 |
39 | {/* */}
47 |
48 |
49 | >
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/pages/api/auth.js:
--------------------------------------------------------------------------------
1 | import { supabase } from '../../libs/initSupabase'
2 |
3 | export default function handler(req, res) {
4 | supabase.auth.api.setAuthCookie(req, res)
5 | }
6 |
--------------------------------------------------------------------------------
/pages/api/index.js:
--------------------------------------------------------------------------------
1 | import sendRes from '../../libs/send-res-with-module-map'
2 |
3 | export default async (req, res) => {
4 | // if `id` is undefined, it points to /react endpoint
5 | if (req.method !== 'GET') {
6 | return res.send('Method not allowed.')
7 | }
8 |
9 | sendRes(req, res, null)
10 | }
11 |
--------------------------------------------------------------------------------
/pages/api/notes/[id].js:
--------------------------------------------------------------------------------
1 | import sendRes from '../../../libs/send-res-with-module-map'
2 | import { supabase } from '../../../libs/initSupabase'
3 |
4 | export default async (req, res) => {
5 | const id = +req.query.id
6 | const { user } = await supabase.auth.api.getUserByCookie(req)
7 |
8 | const { data: note, error } = await supabase
9 | .from('notes')
10 | .select()
11 | .eq('id', id)
12 | .single()
13 |
14 | if (error) {
15 | console.log('error', error.message)
16 | return res.send(error.message)
17 | }
18 |
19 | if (req.method === 'GET') {
20 | return res.send(note)
21 | }
22 |
23 | if (req.method === 'DELETE') {
24 | if (!user || user.id !== note.created_by) {
25 | return res.status(403).send('Unauthorized')
26 | }
27 |
28 | const { error: deleteError } = await supabase
29 | .from('notes')
30 | .delete()
31 | .eq('id', id)
32 | if (deleteError) console.log('Error while deleting:', deleteError)
33 |
34 | return sendRes(req, res, null)
35 | }
36 |
37 | if (req.method === 'PUT') {
38 | if (!user || user.id !== note.created_by) {
39 | return res.status(403).send('Unauthorized')
40 | }
41 |
42 | const updated = {
43 | id,
44 | title: (req.body.title || '').slice(0, 255),
45 | updated_at: new Date(),
46 | body: (req.body.body || '').slice(0, 2048),
47 | created_by: user.id,
48 | }
49 |
50 | const { error: updateError } = await supabase
51 | .from('notes')
52 | .update(updated)
53 | .eq('id', id)
54 | if (updateError) console.log('Error while updating:', updateError)
55 |
56 | return sendRes(req, res, null)
57 | }
58 |
59 | return res.send('Method not allowed.')
60 | }
61 |
--------------------------------------------------------------------------------
/pages/api/notes/index.js:
--------------------------------------------------------------------------------
1 | import sendRes from '../../../libs/send-res-with-module-map'
2 | import { supabase } from '../../../libs/initSupabase'
3 |
4 | export default async (req, res) => {
5 | const { user } = await supabase.auth.api.getUserByCookie(req)
6 |
7 | if (req.method === 'GET') {
8 | const { data: notes, error } = await supabase
9 | .from('notes')
10 | .select('*')
11 | .order('id')
12 |
13 | return res.send(notes)
14 | }
15 |
16 | if (req.method === 'POST') {
17 | if (!user) {
18 | return res.status(403).send('Unauthorized')
19 | }
20 |
21 | const newNote = {
22 | title: (req.body.title || '').slice(0, 255),
23 | body: (req.body.body || '').slice(0, 2048),
24 | created_by: user.id,
25 | }
26 |
27 | const { data: note, error } = await supabase
28 | .from('notes')
29 | .insert([newNote])
30 | .single()
31 |
32 | return sendRes(req, res, note.id)
33 | }
34 |
35 | return res.send('Method not allowed.')
36 | }
37 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import Root from '../components/Root.client'
2 |
3 | export default Root
4 |
--------------------------------------------------------------------------------
/public/checkmark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/chevron-down.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/chevron-up.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/cross.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supabase-community/next-server-components/d5a72266f8f5e92093fb501a138852af5faa86d3/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 | React Logo
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/menu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supabase-community/next-server-components/d5a72266f8f5e92093fb501a138852af5faa86d3/public/og.png
--------------------------------------------------------------------------------
/public/x.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/schema.sql:
--------------------------------------------------------------------------------
1 | /**
2 | * USERS
3 | * Note: This table contains user data. Users should only be able to view and update their own data.
4 | */
5 | create table users (
6 | -- UUID from auth.users
7 | id uuid references auth.users not null primary key,
8 | email text,
9 | full_name text,
10 | avatar_url text
11 | );
12 | alter table users enable row level security;
13 | create policy "Can view own user data." on users for select using (auth.uid() = id);
14 | create policy "Can update own user data." on users for update using (auth.uid() = id);
15 |
16 | /**
17 | * This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
18 | */
19 | create function extensions.handle_new_user()
20 | returns trigger as $$
21 | begin
22 | insert into public.users (id, email, full_name, avatar_url)
23 | values (new.id, new.email, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
24 | return new;
25 | end;
26 | $$ language plpgsql security definer;
27 | create trigger on_auth_user_created
28 | after insert on auth.users
29 | for each row execute procedure extensions.handle_new_user();
30 |
31 | /**
32 | * NOTES
33 | * Note: Stores the notes.
34 | */
35 | create table notes (
36 | id bigint generated by default as identity primary key,
37 | inserted_at timestamp with time zone default timezone('utc'::text, now()) not null,
38 | updated_at timestamp with time zone default timezone('utc'::text, now()) not null,
39 | created_by uuid references public.users not null,
40 | title text,
41 | body text
42 | );
43 |
44 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack')
2 | const path = require('path')
3 |
4 | webpack(
5 | {
6 | mode: 'production',
7 | entry: './libs/send-res.js',
8 | output: {
9 | path: path.resolve('./libs'),
10 | filename: 'send-res.build.js',
11 | libraryTarget: 'commonjs2',
12 | },
13 | optimization: {
14 | // minimize: false
15 | },
16 | module: {
17 | rules: [
18 | {
19 | test: /\.client\.js/,
20 | use: {
21 | loader: path.resolve('./scripts/client-react-loader.js'),
22 | },
23 | },
24 | {
25 | test: /\.js$/,
26 | exclude: /(node_modules)/,
27 | use: {
28 | loader: 'babel-loader',
29 | options: {
30 | presets: ['@babel/preset-react'],
31 | plugins: ['@babel/transform-modules-commonjs'],
32 | },
33 | },
34 | },
35 | ],
36 | },
37 | stats: 'errors-only',
38 | target: 'node',
39 | },
40 | (err, stats) => {
41 | if (err) {
42 | console.error(err)
43 | }
44 | if (stats.hasErrors()) {
45 | const info = stats.toJson()
46 | console.error(info.errors)
47 | }
48 | }
49 | )
50 |
--------------------------------------------------------------------------------
/scripts/client-react-loader.js:
--------------------------------------------------------------------------------
1 | module.exports = function () {
2 | return `
3 | module.exports = {
4 | '__esModule': true,
5 | '$$typeof': Symbol.for('react.module.reference'),
6 | filepath: 'file://${this.resourcePath}',
7 | name: '*',
8 | defaultProps: undefined,
9 | default: {
10 | '$$typeof': Symbol.for('react.module.reference'),
11 | filepath: 'file://${this.resourcePath}',
12 | name: ''
13 | }
14 | }`
15 | }
16 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | /* -------------------------------- CSSRESET --------------------------------*/
2 | /* CSS Reset adapted from https://dev.to/hankchizljaw/a-modern-css-reset-6p3 */
3 | /* Box sizing rules */
4 | *,
5 | *::before,
6 | *::after {
7 | box-sizing: border-box;
8 | }
9 |
10 | /* Remove default padding */
11 | ul[class],
12 | ol[class] {
13 | padding: 0;
14 | }
15 |
16 | /* Remove default margin */
17 | body,
18 | h1,
19 | h2,
20 | h3,
21 | h4,
22 | p,
23 | ul[class],
24 | ol[class],
25 | li,
26 | figure,
27 | figcaption,
28 | blockquote,
29 | dl,
30 | dd {
31 | margin: 0;
32 | }
33 |
34 | /* Set core body defaults */
35 | body {
36 | scroll-behavior: smooth;
37 | text-rendering: optimizeSpeed;
38 | line-height: 1.5;
39 | }
40 |
41 | /* Remove list styles on ul, ol elements with a class attribute */
42 | ul[class],
43 | ol[class] {
44 | list-style: none;
45 | }
46 |
47 | /* A elements that don't have a class get default styles */
48 | a:not([class]) {
49 | text-decoration-skip-ink: auto;
50 | }
51 |
52 | /* Make images easier to work with */
53 | img {
54 | max-width: 100%;
55 | display: block;
56 | }
57 |
58 | /* Natural flow and rhythm in articles by default */
59 | article > * + * {
60 | margin-block-start: 1em;
61 | }
62 |
63 | /* Inherit fonts for inputs and buttons */
64 | input,
65 | button,
66 | textarea,
67 | select {
68 | font: inherit;
69 | -webkit-tap-highlight-color: transparent;
70 | }
71 |
72 | /* Remove all animations and transitions for people that prefer not to see them */
73 | @media (prefers-reduced-motion: reduce) {
74 | * {
75 | animation-duration: 0.01ms !important;
76 | animation-iteration-count: 1 !important;
77 | transition-duration: 0.01ms !important;
78 | scroll-behavior: auto !important;
79 | }
80 | }
81 | /* -------------------------------- /CSSRESET --------------------------------*/
82 |
83 | :root {
84 | /* Colors */
85 | --main-border-color: #ddd;
86 | --primary-border: #037dba;
87 | --gray-20: #404346;
88 | --gray-60: #8a8d91;
89 | --gray-70: #bcc0c4;
90 | --gray-80: #c9ccd1;
91 | --gray-90: #e4e6eb;
92 | --gray-95: #f0f2f5;
93 | --gray-100: #f5f7fa;
94 | --primary-blue: #037dba;
95 | --secondary-blue: #0396df;
96 | --tertiary-blue: #c6efff;
97 | --flash-blue: #4cf7ff;
98 | --outline-blue: rgba(4, 164, 244, 0.6);
99 | --navy-blue: #035e8c;
100 | --red-25: #bd0d2a;
101 | --secondary-text: #65676b;
102 | --white: #fff;
103 | --yellow: #fffae1;
104 |
105 | --outline-box-shadow: 0 0 0 2px var(--outline-blue);
106 | --outline-box-shadow-contrast: 0 0 0 2px var(--navy-blue);
107 |
108 | /* Fonts */
109 | --sans-serif: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto,
110 | Ubuntu, Helvetica, sans-serif;
111 | --monospace: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,
112 | monospace;
113 | }
114 |
115 | html {
116 | font-size: 100%;
117 | height: 100%;
118 | }
119 |
120 | body {
121 | font-family: var(--sans-serif);
122 | background: var(--gray-100);
123 | font-weight: 400;
124 | line-height: 1.75;
125 | height: 100%;
126 | }
127 |
128 | h1,
129 | h2,
130 | h3,
131 | h4,
132 | h5 {
133 | margin: 0;
134 | font-weight: 700;
135 | line-height: 1.3;
136 | }
137 |
138 | h1 {
139 | font-size: 3.052rem;
140 | }
141 | h2 {
142 | font-size: 2.441rem;
143 | }
144 | h3 {
145 | font-size: 1.953rem;
146 | }
147 | h4 {
148 | font-size: 1.563rem;
149 | }
150 | h5 {
151 | font-size: 1.25rem;
152 | }
153 | small,
154 | .text_small {
155 | font-size: 0.8rem;
156 | }
157 | pre,
158 | code {
159 | font-family: var(--monospace);
160 | border-radius: 6px;
161 | }
162 | pre {
163 | background: var(--gray-95);
164 | padding: 12px;
165 | line-height: 1.5;
166 | overflow: auto;
167 | }
168 | code {
169 | background: var(--yellow);
170 | padding: 0 3px;
171 | font-size: 0.94rem;
172 | word-break: break-word;
173 | }
174 | pre code {
175 | background: none;
176 | }
177 | a {
178 | color: var(--primary-blue);
179 | }
180 |
181 | .text-with-markdown h1,
182 | .text-with-markdown h2,
183 | .text-with-markdown h3,
184 | .text-with-markdown h4,
185 | .text-with-markdown h5 {
186 | margin-block: 2rem 0.7rem;
187 | margin-inline: 0;
188 | }
189 |
190 | .text-with-markdown blockquote {
191 | font-style: italic;
192 | color: var(--gray-20);
193 | border-left: 3px solid var(--gray-80);
194 | padding-left: 10px;
195 | }
196 |
197 | hr {
198 | border: 0;
199 | height: 0;
200 | border-top: 1px solid rgba(0, 0, 0, 0.1);
201 | border-bottom: 1px solid rgba(255, 255, 255, 0.3);
202 | }
203 |
204 | /* ---------------------------------------------------------------------------*/
205 | #__next {
206 | height: 100%;
207 | }
208 |
209 | .container {
210 | height: 100%;
211 | display: flex;
212 | flex-direction: column;
213 | }
214 |
215 | .main {
216 | flex: 1;
217 | position: relative;
218 | display: flex;
219 | width: 100%;
220 | overflow: hidden;
221 | }
222 |
223 | .col {
224 | height: 100%;
225 | }
226 | .col:last-child {
227 | flex-grow: 1;
228 | }
229 |
230 | .logo {
231 | height: 20px;
232 | width: 22px;
233 | margin-inline-end: 10px;
234 | }
235 |
236 | .avatar {
237 | width: 20px;
238 | height: 20px;
239 | display: inline-block;
240 | border-radius: 100%;
241 | border: 1px solid #ccc;
242 | vertical-align: -5px;
243 | margin-left: 5px;
244 | }
245 | button .avatar {
246 | margin-right: -5px;
247 | }
248 | .edit-button {
249 | border-radius: 100px;
250 | letter-spacing: 0.12em;
251 | text-transform: uppercase;
252 | padding: 4px 14px;
253 | cursor: pointer;
254 | font-weight: 700;
255 | font-size: 14px;
256 | outline-style: none;
257 | white-space: nowrap;
258 | flex: 0 0 auto;
259 | }
260 | .edit-button--solid {
261 | background: var(--primary-blue);
262 | color: var(--white);
263 | border: none;
264 | margin: 0 0 0 10px;
265 | transition: all 0.2s ease-in-out;
266 | }
267 | @media (hover: hover) {
268 | .edit-button--solid:hover {
269 | background: var(--secondary-blue);
270 | }
271 | }
272 | .edit-button--solid:focus {
273 | box-shadow: var(--outline-box-shadow-contrast);
274 | }
275 | .edit-button--outline {
276 | background: var(--white);
277 | color: var(--primary-blue);
278 | border: 1px solid var(--primary-blue);
279 | margin: 0 0 0 12px;
280 | transition: all 0.1s ease-in-out;
281 | }
282 | .edit-button--outline:disabled {
283 | opacity: 0.5;
284 | }
285 | @media (hover: hover) {
286 | .edit-button--outline:hover:not([disabled]) {
287 | background: var(--primary-blue);
288 | color: var(--white);
289 | }
290 | }
291 | .edit-button--outline:focus {
292 | box-shadow: var(--outline-box-shadow);
293 | }
294 |
295 | ul.notes-list {
296 | padding: 16px 0;
297 | }
298 | .notes-list > li {
299 | padding: 0 16px;
300 | }
301 | .notes-empty {
302 | padding: 16px;
303 | }
304 |
305 | .sidebar {
306 | background: var(--white);
307 | box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.1), 0px 2px 2px rgba(0, 0, 0, 0.1);
308 | overflow-y: scroll;
309 | z-index: 1000;
310 | flex-shrink: 0;
311 | max-width: 350px;
312 | min-width: 250px;
313 | width: 30%;
314 | height: auto;
315 | }
316 | .sidebar-toggle {
317 | display: none;
318 | position: fixed;
319 | z-index: 9999;
320 | top: 40px;
321 | left: 10px;
322 | margin: 0;
323 | width: 30px;
324 | height: 30px;
325 | background: url(/x.svg) #e2e2e2;
326 | border-radius: 6px;
327 | border: none;
328 | appearance: none;
329 | background-size: 24px;
330 | background-position: center;
331 | background-repeat: no-repeat;
332 | }
333 | .sidebar-header {
334 | letter-spacing: 0.12em;
335 | text-transform: uppercase;
336 | padding: 16px;
337 | display: flex;
338 | align-items: center;
339 | }
340 | .sidebar-menu {
341 | padding: 0 16px;
342 | display: flex;
343 | justify-content: space-between;
344 | }
345 | .sidebar-menu > .search {
346 | position: relative;
347 | flex-grow: 1;
348 | }
349 | .sidebar-note-list-item {
350 | position: relative;
351 | margin-bottom: 12px;
352 | padding: 16px;
353 | width: 100%;
354 | display: flex;
355 | justify-content: space-between;
356 | align-items: flex-start;
357 | flex-wrap: wrap;
358 | max-height: 100px;
359 | transition: max-height 250ms ease-out;
360 | transform: scale(1);
361 | }
362 | .sidebar-note-list-item.note-expanded {
363 | max-height: 300px;
364 | transition: max-height 0.5s ease;
365 | }
366 | .sidebar-note-list-item.flash {
367 | animation-name: flash;
368 | animation-duration: 0.6s;
369 | }
370 |
371 | .screen-center {
372 | height: 100vh;
373 | width: 100vw;
374 | display: flex;
375 | align-items: center;
376 | justify-content: center;
377 | }
378 |
379 | @media screen and (max-width: 640px) {
380 | .banner:before {
381 | content: '⚠️ React Server Components are experimental. ';
382 | }
383 | .banner {
384 | font-size: 13px;
385 | }
386 | .sidebar-toggle {
387 | display: block;
388 | }
389 | .sidebar {
390 | position: absolute;
391 | transition: all 0.4s ease;
392 | height: 100%;
393 | }
394 | .sidebar-toggle:checked {
395 | background: url(/menu.svg) #e2e2e2;
396 | background-size: 24px;
397 | background-position: center;
398 | background-repeat: no-repeat;
399 | }
400 | .sidebar-toggle:checked + .sidebar {
401 | transform: translateX(-120%);
402 | }
403 | .sidebar-toggle:not(:checked) ~ .note-viewer {
404 | opacity: 0.2;
405 | pointer-events: none;
406 | }
407 | .sidebar-header {
408 | padding-top: 14px;
409 | justify-content: flex-end;
410 | }
411 | .note-editor-done,
412 | .note-editor-delete,
413 | .edit-button {
414 | padding: 3px 10px !important;
415 | margin: 0 0 0 4px !important;
416 | letter-spacing: 0.03em !important;
417 | font-size: 13px !important;
418 | }
419 | .label {
420 | padding: 4px 10px !important;
421 | font-size: 13px !important;
422 | }
423 | input,
424 | textarea {
425 | font-size: 16px !important;
426 | }
427 | }
428 |
429 | .sidebar-note-open {
430 | position: absolute;
431 | top: 0;
432 | left: 0;
433 | right: 0;
434 | bottom: 0;
435 | width: 100%;
436 | z-index: 0;
437 | border: none;
438 | border-radius: 6px;
439 | text-align: start;
440 | background: var(--gray-95);
441 | cursor: pointer;
442 | outline-style: none;
443 | color: transparent;
444 | font-size: 0px;
445 | appearance: none;
446 | }
447 | .sidebar-note-open:focus {
448 | box-shadow: var(--outline-box-shadow);
449 | }
450 | @media (hover: hover) {
451 | .sidebar-note-open:hover {
452 | background: var(--gray-90);
453 | }
454 | }
455 | .sidebar-note-header {
456 | z-index: 1;
457 | max-width: 85%;
458 | pointer-events: none;
459 | }
460 | .sidebar-note-header > strong {
461 | display: block;
462 | font-size: 1.25rem;
463 | line-height: 1.2;
464 | white-space: nowrap;
465 | overflow: hidden;
466 | text-overflow: ellipsis;
467 | }
468 | .sidebar-note-toggle-expand {
469 | z-index: 2;
470 | border-radius: 50%;
471 | height: 24px;
472 | width: 24px;
473 | padding: 0 6px;
474 | border: 1px solid var(--gray-60);
475 | cursor: pointer;
476 | flex-shrink: 0;
477 | visibility: hidden;
478 | opacity: 0;
479 | cursor: default;
480 | transition: visibility 0s linear 20ms, opacity 300ms;
481 | outline-style: none;
482 | }
483 | .sidebar-note-toggle-expand:focus {
484 | box-shadow: var(--outline-box-shadow);
485 | }
486 |
487 | .sidebar-note-open:focus + .sidebar-note-toggle-expand,
488 | .sidebar-note-toggle-expand:focus {
489 | visibility: visible;
490 | opacity: 1;
491 | transition: visibility 0s linear 0s, opacity 300ms;
492 | }
493 |
494 | @media (hover: hover) {
495 | .sidebar-note-open:hover + .sidebar-note-toggle-expand,
496 | .sidebar-note-toggle-expand:hover {
497 | visibility: visible;
498 | opacity: 1;
499 | transition: visibility 0s linear 0s, opacity 300ms;
500 | }
501 | }
502 | .sidebar-note-toggle-expand img {
503 | width: 10px;
504 | height: 10px;
505 | }
506 |
507 | .sidebar-note-excerpt {
508 | pointer-events: none;
509 | z-index: 2;
510 | flex: 1 1 250px;
511 | color: var(--secondary-text);
512 | position: relative;
513 | animation: slideIn 100ms;
514 | }
515 |
516 | .search input {
517 | padding: 0 16px;
518 | border-radius: 100px;
519 | border: 1px solid var(--gray-90);
520 | width: 100%;
521 | height: 100%;
522 | outline-style: none;
523 | appearance: none;
524 | }
525 | .search input:focus {
526 | box-shadow: var(--outline-box-shadow);
527 | }
528 | .search .spinner {
529 | position: absolute;
530 | right: 10px;
531 | top: 10px;
532 | }
533 |
534 | .note-viewer {
535 | display: flex;
536 | align-items: center;
537 | justify-content: center;
538 | height: auto;
539 | max-width: 100%;
540 | transition: opacity 0.3s ease;
541 | }
542 | .note {
543 | background: var(--white);
544 | box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1), 0px 0px 2px rgba(0, 0, 0, 0.1);
545 | border-radius: 8px;
546 | width: calc(100% - 32px);
547 | height: calc(100% - 32px);
548 | padding: 16px;
549 | overflow-y: auto;
550 | }
551 | .note--empty-state {
552 | margin: 20px;
553 | text-align: center;
554 | }
555 | .note-text--empty-state {
556 | font-size: 1.5rem;
557 | }
558 | .note-header {
559 | display: flex;
560 | justify-content: space-between;
561 | align-items: center;
562 | flex-wrap: wrap-reverse;
563 | }
564 | .note-menu {
565 | display: flex;
566 | justify-content: space-between;
567 | align-items: center;
568 | flex-grow: 1;
569 | margin-bottom: 10px;
570 | overflow: hidden;
571 | width: 100%;
572 | height: 34px;
573 | }
574 | .note-title {
575 | line-height: 1.3;
576 | flex-grow: 1;
577 | overflow-wrap: break-word;
578 | word-break: break-word;
579 | }
580 | .note-updated-at {
581 | color: var(--secondary-text);
582 | white-space: nowrap;
583 | text-overflow: ellipsis;
584 | overflow: hidden;
585 | }
586 | .note-preview {
587 | margin: 50px 0;
588 | }
589 |
590 | .note-editor {
591 | display: flex;
592 | padding: 16px;
593 | background: var(--white);
594 | box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1), 0px 0px 2px rgba(0, 0, 0, 0.1);
595 | border-radius: 8px;
596 | width: calc(100% - 32px);
597 | height: calc(100% - 32px);
598 | overflow-y: auto;
599 | }
600 | .note-editor .label {
601 | margin-bottom: 10px;
602 | }
603 | .note-editor-form {
604 | display: flex;
605 | flex-direction: column;
606 | width: 400px;
607 | max-width: 50%;
608 | flex-shrink: 0;
609 | position: sticky;
610 | top: 0;
611 | }
612 | .note-editor-form input,
613 | .note-editor-form textarea {
614 | background: none;
615 | border: 1px solid var(--gray-70);
616 | border-radius: 4px;
617 | font-family: var(--monospace);
618 | font-size: 0.8rem;
619 | padding: 12px;
620 | outline-style: none;
621 | appearance: none;
622 | }
623 | .note-editor-form input:focus,
624 | .note-editor-form textarea:focus {
625 | box-shadow: var(--outline-box-shadow);
626 | }
627 | .note-editor-form input {
628 | height: 44px;
629 | margin-bottom: 16px;
630 | }
631 | .note-editor-form textarea {
632 | height: 100%;
633 | max-width: 400px;
634 | }
635 | .note-editor-menu {
636 | display: flex;
637 | justify-content: flex-end;
638 | align-items: center;
639 | margin-bottom: 10px;
640 | float: right;
641 | }
642 | .note-editor-preview {
643 | margin-inline-start: 40px;
644 | width: 100%;
645 | }
646 | .note-editor-done,
647 | .note-editor-delete {
648 | display: flex;
649 | justify-content: space-between;
650 | align-items: center;
651 | border-radius: 100px;
652 | letter-spacing: 0.12em;
653 | text-transform: uppercase;
654 | padding: 4px 14px;
655 | cursor: pointer;
656 | font-weight: 700;
657 | font-size: 14px;
658 | margin: 0 0 0 10px;
659 | outline-style: none;
660 | transition: all 0.2s ease-in-out;
661 | }
662 | .note-editor-done:disabled,
663 | .note-editor-delete:disabled {
664 | opacity: 0.5;
665 | }
666 | .note-editor-done {
667 | border: none;
668 | background: var(--primary-blue);
669 | color: var(--white);
670 | }
671 | .note-editor-done:focus {
672 | box-shadow: var(--outline-box-shadow-contrast);
673 | }
674 | @media (hover: hover) {
675 | .note-editor-done:hover:not([disabled]) {
676 | background: var(--secondary-blue);
677 | }
678 | }
679 | .note-editor-delete {
680 | border: 1px solid var(--red-25);
681 | background: var(--white);
682 | color: var(--red-25);
683 | }
684 | .note-editor-delete:focus {
685 | box-shadow: var(--outline-box-shadow);
686 | }
687 | @media (hover: hover) {
688 | .note-editor-delete:hover:not([disabled]) {
689 | background: var(--red-25);
690 | color: var(--white);
691 | }
692 | /* Hack to color our svg */
693 | .note-editor-delete:hover:not([disabled]) img {
694 | filter: grayscale(1) invert(1) brightness(2);
695 | }
696 | }
697 | .note-editor-done > img {
698 | width: 14px;
699 | }
700 | .note-editor-delete > img {
701 | width: 10px;
702 | }
703 | .note-editor-done > img,
704 | .note-editor-delete > img {
705 | margin-inline-end: 12px;
706 | }
707 | .note-editor-done[disabled],
708 | .note-editor-delete[disabled] {
709 | opacity: 0.5;
710 | }
711 |
712 | .label {
713 | display: inline-block;
714 | border-radius: 100px;
715 | text-transform: uppercase;
716 | font-weight: 700;
717 | font-size: 14px;
718 | padding: 5px 14px;
719 | }
720 | .label--preview {
721 | background: rgba(38, 183, 255, 0.15);
722 | color: var(--primary-blue);
723 | }
724 |
725 | .text-with-markdown p {
726 | margin-bottom: 16px;
727 | }
728 | .text-with-markdown img {
729 | width: 100%;
730 | }
731 |
732 | /* https://codepen.io/mandelid/pen/vwKoe */
733 | .spinner {
734 | display: inline-block;
735 | transition: opacity linear 0.1s;
736 | width: 20px;
737 | height: 20px;
738 | border: 3px solid rgba(80, 80, 80, 0.5);
739 | border-radius: 50%;
740 | border-top-color: #fff;
741 | animation: spin 1s ease-in-out infinite;
742 | opacity: 0;
743 | }
744 | .spinner--active {
745 | opacity: 1;
746 | }
747 |
748 | .skeleton::after {
749 | content: 'Loading...';
750 | }
751 | .skeleton {
752 | height: 100%;
753 | background-color: #eee;
754 | background-image: linear-gradient(90deg, #eee, #f5f5f5, #eee);
755 | background-size: 200px 100%;
756 | background-repeat: no-repeat;
757 | border-radius: 4px;
758 | display: block;
759 | line-height: 1;
760 | width: 100%;
761 | animation: shimmer 1.2s ease-in-out infinite;
762 | color: transparent;
763 | }
764 | .skeleton:first-of-type {
765 | margin: 0;
766 | }
767 | .skeleton--button {
768 | border-radius: 100px;
769 | padding: 6px 20px;
770 | width: auto;
771 | }
772 | .v-stack + .v-stack {
773 | margin-block-start: 0.8em;
774 | }
775 |
776 | .offscreen {
777 | border: 0;
778 | clip: rect(0, 0, 0, 0);
779 | height: 1px;
780 | margin: -1px;
781 | overflow: hidden;
782 | padding: 0;
783 | width: 1px;
784 | position: absolute;
785 | }
786 |
787 | @media screen and (max-width: 900px) {
788 | .note-editor {
789 | flex-direction: column-reverse;
790 | }
791 | .note-editor-preview {
792 | margin-inline-start: 0;
793 | overflow: auto;
794 | margin-bottom: 20px;
795 | flex: 2;
796 | }
797 | .note-editor-form {
798 | flex: 1;
799 | width: 100%;
800 | max-width: 100%;
801 | }
802 | .note-editor-form textarea {
803 | max-width: 100%;
804 | }
805 | }
806 |
807 | /* ---------------------------------------------------------------------------*/
808 | @keyframes spin {
809 | to {
810 | transform: rotate(360deg);
811 | }
812 | }
813 | @keyframes spin {
814 | to {
815 | transform: rotate(360deg);
816 | }
817 | }
818 |
819 | @keyframes shimmer {
820 | 0% {
821 | background-position: -200px 0;
822 | }
823 | 100% {
824 | background-position: calc(200px + 100%) 0;
825 | }
826 | }
827 |
828 | @keyframes slideIn {
829 | 0% {
830 | top: -10px;
831 | opacity: 0;
832 | }
833 | 100% {
834 | top: 0;
835 | opacity: 1;
836 | }
837 | }
838 |
839 | @keyframes flash {
840 | 0% {
841 | transform: scale(1);
842 | opacity: 1;
843 | }
844 | 50% {
845 | transform: scale(1.05);
846 | opacity: 0.9;
847 | }
848 | 100% {
849 | transform: scale(1);
850 | opacity: 1;
851 | }
852 | }
853 |
--------------------------------------------------------------------------------