├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── app ├── dashboard │ └── page.tsx ├── default.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── normalize.css ├── page.module.css ├── page.tsx └── whiteboard │ ├── [id] │ ├── @dashPreview │ │ ├── (...)dashboard │ │ │ └── page.tsx │ │ └── default.tsx │ ├── WhiteboardPage.module.css │ ├── layout.tsx │ └── page.tsx │ └── page.tsx ├── components ├── Cursors │ ├── Cursor.module.css │ ├── Cursor.tsx │ ├── Cursors.tsx │ └── index.ts ├── Dashboard │ ├── Dashboard.module.css │ ├── Dashboard.tsx │ ├── DocumentIcon.tsx │ ├── DocumentRow.module.css │ └── DocumentRow.tsx ├── Header │ ├── Header.module.css │ └── Header.tsx ├── Logo │ ├── Logo.module.css │ ├── Logo.tsx │ └── index.ts └── Whiteboard │ ├── Whiteboard.module.css │ ├── Whiteboard.tsx │ ├── WhiteboardNote.module.css │ ├── WhiteboardNote.tsx │ └── index.ts ├── icons ├── Cross.tsx ├── Plus.tsx ├── Redo.tsx ├── Undo.tsx └── index.ts ├── liveblocks.config.ts ├── next.config.js ├── package-lock.json ├── package.json ├── primitives ├── Avatar │ ├── Avatar.module.css │ ├── Avatar.tsx │ └── index.ts ├── Button │ ├── Button.module.css │ ├── Button.tsx │ └── index.ts ├── Container │ ├── Container.module.css │ ├── Container.tsx │ └── index.ts ├── Input │ ├── Input.module.css │ ├── Input.tsx │ └── index.ts ├── Skeleton │ ├── Skeleton.module.css │ ├── Skeleton.tsx │ └── index.ts ├── Spinner │ ├── Spinner.module.css │ ├── Spinner.tsx │ └── index.ts └── Tooltip │ ├── Tooltip.module.css │ ├── Tooltip.tsx │ └── index.ts ├── public ├── next.svg └── vercel.svg ├── tsconfig.json └── utils ├── capitalize.ts ├── getContrastingColor.ts ├── getInitials.ts ├── index.ts ├── normalizeTrailingSlash.ts ├── randomUser.ts └── useBoundingClientRectRef.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:prettier/recommended", 10 | "next", 11 | ], 12 | parser: "@typescript-eslint/parser", 13 | plugins: ["@typescript-eslint", "react", "react-hooks", "prettier"], 14 | rules: { 15 | "@typescript-eslint/ban-ts-comment": "off", 16 | "@typescript-eslint/ban-types": "off", 17 | "@typescript-eslint/no-empty-function": "off", 18 | "@typescript-eslint/no-explicit-any": "off", 19 | "@typescript-eslint/no-non-null-assertion": "off", 20 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 21 | "@typescript-eslint/no-use-before-define": "off", 22 | "@typescript-eslint/no-var-requires": "off", 23 | "react/display-name": "off", 24 | "react/react-in-jsx-scope": "off", 25 | "react/prop-types": "off", 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | .idea 38 | .vscode 39 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": false, 6 | "jsxSingleQuote": false, 7 | "arrowParens": "always", 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "trailingComma": "es5", 11 | "proseWrap": "always" 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | https://user-images.githubusercontent.com/33033422/231229414-c1c627c6-babd-4b99-9393-ecb20ec06afe.mp4 2 | 3 | ## Next.js 13.3 parallel & intercepted routes + Liveblocks demo 4 | 5 | This demo shows you how to use Next.js 13.3 parallel & intercepted routes with a Liveblocks real-time collaborative app. Stay connected to a Liveblocks room whilst viewing the dashboard. 6 | 7 | ### Set up Liveblocks 8 | 9 | - Install all dependencies with `npm install` 10 | - Create an account on [liveblocks.io](https://liveblocks.io/dashboard) 11 | - Copy your **public** key from the [dashboard](https://liveblocks.io/dashboard/apikeys) 12 | - Create an `.env.local` file and add your **public** key as the `NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY` environment variable 13 | - Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) 14 | -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Dashboard } from "@/components/Dashboard/Dashboard"; 2 | import { Header } from "@/components/Header/Header"; 3 | 4 | export const metadata = { 5 | title: "Dashboard", 6 | }; 7 | 8 | export default function Home() { 9 | return ( 10 | <> 11 | 12 |
13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CTNicholas/liveblocks-parallel-routes/3469a1a2fdf914a707d2ce81918dca86d65513f5/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "./normalize.css"; 2 | 3 | :root { 4 | --color-black: 0 0 0; 5 | --color-white: 255 255 255; 6 | --color-gray-50: 250 250 250; 7 | --color-gray-100: 244 244 245; 8 | --color-gray-150: 236 236 238; 9 | --color-gray-200: 228 228 231; 10 | --color-gray-250: 220 220 224; 11 | --color-gray-300: 212 212 216; 12 | --color-gray-350: 187 187 193; 13 | --color-gray-400: 161 161 170; 14 | --color-gray-450: 137 137 146; 15 | --color-gray-500: 113 113 122; 16 | --color-gray-550: 98 98 107; 17 | --color-gray-600: 82 82 91; 18 | --color-gray-650: 73 73 81; 19 | --color-gray-700: 63 63 70; 20 | --color-gray-750: 51 51 56; 21 | --color-gray-800: 39 39 42; 22 | --color-gray-850: 32 32 35; 23 | --color-gray-900: 24 24 27; 24 | --color-gray-950: 18 18 21; 25 | --color-overlay: rgb(var(--color-black) / 60%); 26 | --space-0: 0; 27 | --space-1: 0.125rem; 28 | --space-2: 0.25rem; 29 | --space-3: 0.375rem; 30 | --space-4: 0.5rem; 31 | --space-5: 0.625rem; 32 | --space-6: 0.75rem; 33 | --space-7: 0.875rem; 34 | --space-8: 1rem; 35 | --space-9: 1.25rem; 36 | --space-10: 1.5rem; 37 | --space-11: 1.75rem; 38 | --space-12: 2rem; 39 | --space-13: 2.25rem; 40 | --space-14: 2.5rem; 41 | --space-15: 3rem; 42 | --space-16: 3.5rem; 43 | --space-17: 4rem; 44 | --space-18: 4.5rem; 45 | --space-19: 5rem; 46 | --space-20: 6rem; 47 | --space-21: 7rem; 48 | --space-22: 8rem; 49 | --space-23: 9rem; 50 | --space-24: 10rem; 51 | --size-2xs: 0.625rem; 52 | --size-xs: 0.75rem; 53 | --size-sm: 0.875rem; 54 | --size: 1rem; 55 | --size-lg: 1.125rem; 56 | --size-xl: 1.25rem; 57 | --size-2xl: 1.5rem; 58 | --size-3xl: 2rem; 59 | --size-4xl: 3rem; 60 | --radius-xs: 0.3rem; 61 | --radius-sm: 0.4rem; 62 | --radius: 0.7rem; 63 | --radius-lg: 0.8rem; 64 | --shadow-xs: 0 1px 4px rgb(var(--color-black) / 5%); 65 | --shadow-sm: 0 2px 8px rgb(var(--color-black) / 5%); 66 | --shadow: 0 3px 10px rgb(var(--color-black) / 5%); 67 | --shadow-lg: 0 4px 20px rgb(var(--color-black) / 5%); 68 | --shadow-xl: 0 5px 30px rgb(var(--color-black) / 5%); 69 | --backdrop-surface: saturate(2) blur(16px); 70 | --transition: 0.15s ease-in-out; 71 | --transition-linear: 0.15s linear; 72 | --header-height: 60px; 73 | --z-above: 100; 74 | --z-overlay: 200; 75 | --z-badge: 1000; 76 | --opacity-hover: 0.8; 77 | --opacity-disabled: 0.5; 78 | 79 | accent-color: var(--color-accent); 80 | } 81 | 82 | /* Light theme */ 83 | :root { 84 | --color-red: 239 67 67; 85 | --color-green: 132 204 22; 86 | --color-accent: rgb(var(--color-gray-900)); 87 | --color-surface: rgb(var(--color-gray-100)); 88 | --color-surface-hover: rgb(var(--color-gray-150)); 89 | --color-surface-elevated: rgb(var(--color-white)); 90 | --color-border: rgb(var(--color-gray-150)); 91 | --color-border-contrasted: rgb(var(--color-gray-250)); 92 | --color-border-transparent: rgb(var(--color-gray-900) / 10%); 93 | --color-skeleton: rgb(var(--color-gray-150)); 94 | --color-skeleton-shine: rgb(var(--color-gray-50)); 95 | --color-tooltip: rgb(var(--color-gray-950)); 96 | --color-tooltip-text: rgb(var(--color-white)); 97 | --color-tooltip-border: rgb(var(--color-gray-750)); 98 | --color-text: rgb(var(--color-gray-900)); 99 | --color-text-light: rgb(var(--color-gray-600)); 100 | --color-text-lighter: rgb(var(--color-gray-500)); 101 | --color-text-lightest: rgb(var(--color-gray-400)); 102 | 103 | color-scheme: light; 104 | } 105 | 106 | /* Dark theme */ 107 | @media (prefers-color-scheme: dark) { 108 | :root { 109 | --color-red: 248 113 113; 110 | --color-green: 162 230 53; 111 | --color-accent: rgb(var(--color-white)); 112 | --color-surface: rgb(var(--color-gray-850)); 113 | --color-surface-hover: rgb(var(--color-gray-800)); 114 | --color-surface-elevated: rgb(var(--color-gray-950)); 115 | --color-border: rgb(var(--color-gray-750)); 116 | --color-border-contrasted: rgb(var(--color-gray-650)); 117 | --color-border-transparent: rgb(var(--color-white) / 10%); 118 | --color-skeleton: rgb(var(--color-gray-750)); 119 | --color-skeleton-shine: rgb(var(--color-gray-850)); 120 | --color-text: rgb(var(--color-white)); 121 | --color-text-light: rgb(var(--color-gray-300)); 122 | --color-text-lighter: rgb(var(--color-gray-400)); 123 | --color-text-lightest: rgb(var(--color-gray-500)); 124 | 125 | color-scheme: dark; 126 | } 127 | } 128 | 129 | html, 130 | body { 131 | background: var(--color-surface-elevated); 132 | color: var(--color-text); 133 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 134 | Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 135 | } 136 | 137 | html, 138 | body, 139 | #__next { 140 | max-width: 100%; 141 | min-height: 100vh; 142 | } 143 | 144 | html.grabbing { 145 | cursor: grabbing; 146 | } 147 | 148 | html.grabbing * { 149 | user-select: none; 150 | } 151 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export default function Layout({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/normalize.css: -------------------------------------------------------------------------------- 1 | *, 2 | ::before, 3 | ::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | html { 8 | line-height: 1.5; 9 | tab-size: 4; 10 | -webkit-text-size-adjust: 100%; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | line-height: inherit; 16 | } 17 | 18 | h1, 19 | h2, 20 | h3, 21 | h4, 22 | h5, 23 | h6 { 24 | font-size: inherit; 25 | font-weight: inherit; 26 | } 27 | 28 | a { 29 | color: inherit; 30 | text-decoration: inherit; 31 | } 32 | 33 | b, 34 | strong { 35 | font-weight: bolder; 36 | } 37 | 38 | code, 39 | kbd, 40 | samp, 41 | pre { 42 | font-size: 1em; 43 | } 44 | 45 | small { 46 | font-size: 80%; 47 | } 48 | 49 | button, 50 | input, 51 | optgroup, 52 | select, 53 | textarea { 54 | padding: 0; 55 | border: 0; 56 | margin: 0; 57 | color: inherit; 58 | font-family: inherit; 59 | font-size: 100%; 60 | font-weight: inherit; 61 | line-height: inherit; 62 | } 63 | 64 | button, 65 | select { 66 | border: none; 67 | text-transform: none; 68 | } 69 | 70 | button, 71 | [type="button"], 72 | [type="reset"], 73 | [type="submit"] { 74 | appearance: button; 75 | background-color: transparent; 76 | background-image: none; 77 | } 78 | 79 | :-moz-focusring { 80 | outline: auto; 81 | } 82 | 83 | :-moz-ui-invalid { 84 | box-shadow: none; 85 | } 86 | 87 | progress { 88 | vertical-align: baseline; 89 | } 90 | 91 | ::-webkit-inner-spin-button, 92 | ::-webkit-outer-spin-button { 93 | height: auto; 94 | } 95 | 96 | [type="search"] { 97 | appearance: textfield; 98 | } 99 | 100 | ::-webkit-search-decoration { 101 | appearance: none; 102 | } 103 | 104 | ::-webkit-file-upload-button { 105 | appearance: button; 106 | font: inherit; 107 | } 108 | 109 | summary { 110 | display: list-item; 111 | } 112 | 113 | blockquote, 114 | dl, 115 | dd, 116 | h1, 117 | h2, 118 | h3, 119 | h4, 120 | h5, 121 | h6, 122 | hr, 123 | figure, 124 | p, 125 | pre { 126 | margin: 0; 127 | } 128 | 129 | fieldset { 130 | padding: 0; 131 | margin: 0; 132 | } 133 | 134 | legend { 135 | padding: 0; 136 | } 137 | 138 | ol, 139 | ul, 140 | menu { 141 | padding: 0; 142 | margin: 0; 143 | list-style: none; 144 | } 145 | 146 | textarea { 147 | resize: vertical; 148 | } 149 | 150 | button, 151 | [role="button"] { 152 | cursor: pointer; 153 | } 154 | 155 | :disabled { 156 | cursor: default; 157 | } 158 | 159 | img, 160 | svg, 161 | video, 162 | canvas, 163 | audio, 164 | iframe, 165 | embed, 166 | object { 167 | display: block; 168 | vertical-align: middle; 169 | } 170 | 171 | img, 172 | video { 173 | max-width: 100%; 174 | height: auto; 175 | } 176 | -------------------------------------------------------------------------------- /app/page.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .description a { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | gap: 0.5rem; 26 | } 27 | 28 | .description p { 29 | position: relative; 30 | margin: 0; 31 | padding: 1rem; 32 | background-color: rgba(var(--callout-rgb), 0.5); 33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3); 34 | border-radius: var(--border-radius); 35 | } 36 | 37 | .code { 38 | font-weight: 700; 39 | font-family: var(--font-mono); 40 | } 41 | 42 | .grid { 43 | display: grid; 44 | grid-template-columns: repeat(4, minmax(25%, auto)); 45 | width: var(--max-width); 46 | max-width: 100%; 47 | } 48 | 49 | .card { 50 | padding: 1rem 1.2rem; 51 | border-radius: var(--border-radius); 52 | background: rgba(var(--card-rgb), 0); 53 | border: 1px solid rgba(var(--card-border-rgb), 0); 54 | transition: background 200ms, border 200ms; 55 | } 56 | 57 | .card span { 58 | display: inline-block; 59 | transition: transform 200ms; 60 | } 61 | 62 | .card h2 { 63 | font-weight: 600; 64 | margin-bottom: 0.7rem; 65 | } 66 | 67 | .card p { 68 | margin: 0; 69 | opacity: 0.6; 70 | font-size: 0.9rem; 71 | line-height: 1.5; 72 | max-width: 30ch; 73 | } 74 | 75 | .center { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | position: relative; 80 | padding: 4rem 0; 81 | } 82 | 83 | .center::before { 84 | background: var(--secondary-glow); 85 | border-radius: 50%; 86 | width: 480px; 87 | height: 360px; 88 | margin-left: -400px; 89 | } 90 | 91 | .center::after { 92 | background: var(--primary-glow); 93 | width: 240px; 94 | height: 180px; 95 | z-index: -1; 96 | } 97 | 98 | .center::before, 99 | .center::after { 100 | content: ''; 101 | left: 50%; 102 | position: absolute; 103 | filter: blur(45px); 104 | transform: translateZ(0); 105 | } 106 | 107 | .logo { 108 | position: relative; 109 | } 110 | 111 | /* Enable hover only on non-touch devices */ 112 | @media (hover: hover) and (pointer: fine) { 113 | .card:hover { 114 | background: rgba(var(--card-rgb), 0.1); 115 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 116 | } 117 | 118 | .card:hover span { 119 | transform: translateX(4px); 120 | } 121 | } 122 | 123 | @media (prefers-reduced-motion) { 124 | .card:hover span { 125 | transform: none; 126 | } 127 | } 128 | 129 | /* Mobile */ 130 | @media (max-width: 700px) { 131 | .content { 132 | padding: 4rem; 133 | } 134 | 135 | .grid { 136 | grid-template-columns: 1fr; 137 | margin-bottom: 120px; 138 | max-width: 320px; 139 | text-align: center; 140 | } 141 | 142 | .card { 143 | padding: 1rem 2.5rem; 144 | } 145 | 146 | .card h2 { 147 | margin-bottom: 0.5rem; 148 | } 149 | 150 | .center { 151 | padding: 8rem 0 6rem; 152 | } 153 | 154 | .center::before { 155 | transform: none; 156 | height: 300px; 157 | } 158 | 159 | .description { 160 | font-size: 0.8rem; 161 | } 162 | 163 | .description a { 164 | padding: 1rem; 165 | } 166 | 167 | .description p, 168 | .description div { 169 | display: flex; 170 | justify-content: center; 171 | position: fixed; 172 | width: 100%; 173 | } 174 | 175 | .description p { 176 | align-items: center; 177 | inset: 0 0 auto; 178 | padding: 2rem 1rem 1.4rem; 179 | border-radius: 0; 180 | border: none; 181 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 182 | background: linear-gradient( 183 | to bottom, 184 | rgba(var(--background-start-rgb), 1), 185 | rgba(var(--callout-rgb), 0.5) 186 | ); 187 | background-clip: padding-box; 188 | backdrop-filter: blur(24px); 189 | } 190 | 191 | .description div { 192 | align-items: flex-end; 193 | pointer-events: none; 194 | inset: auto 0 0; 195 | padding: 2rem; 196 | height: 200px; 197 | background: linear-gradient( 198 | to bottom, 199 | transparent 0%, 200 | rgb(var(--background-end-rgb)) 40% 201 | ); 202 | z-index: 1; 203 | } 204 | } 205 | 206 | /* Tablet and Smaller Desktop */ 207 | @media (min-width: 701px) and (max-width: 1120px) { 208 | .grid { 209 | grid-template-columns: repeat(2, 50%); 210 | } 211 | } 212 | 213 | @media (prefers-color-scheme: dark) { 214 | .vercelLogo { 215 | filter: invert(1); 216 | } 217 | 218 | .logo { 219 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 220 | } 221 | } 222 | 223 | @keyframes rotate { 224 | from { 225 | transform: rotate(360deg); 226 | } 227 | to { 228 | transform: rotate(0deg); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function Home() { 4 | redirect("/dashboard"); 5 | } 6 | -------------------------------------------------------------------------------- /app/whiteboard/[id]/@dashPreview/(...)dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Dashboard } from "@/components/Dashboard/Dashboard"; 2 | 3 | export const metadata = { 4 | title: "Dashboard", 5 | }; 6 | 7 | export default function WhiteboardDashboard() { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /app/whiteboard/[id]/@dashPreview/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /app/whiteboard/[id]/WhiteboardPage.module.css: -------------------------------------------------------------------------------- 1 | .whiteboardPage { 2 | position: absolute; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | transform-origin: bottom right; 8 | transition: transform ease-out 0.15s; 9 | background: var(--color-surface); 10 | } 11 | 12 | .whiteboardPageShrunk { 13 | position: fixed; 14 | border-radius: var(--radius-lg); 15 | box-shadow: var(--shadow-lg); 16 | transform: scale(0.4); 17 | right: var(--space-8); 18 | bottom: var(--space-8); 19 | border: 2px solid rgb(var(--color-gray-200)); 20 | } 21 | 22 | .whiteboardPageBackButton { 23 | position: absolute; 24 | top: 0; 25 | right: 0; 26 | bottom: 0; 27 | left: 0; 28 | width: 100%; 29 | height: 100%; 30 | } 31 | -------------------------------------------------------------------------------- /app/whiteboard/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export const metadata = { 4 | title: "Whiteboard", 5 | }; 6 | 7 | export default function Layout({ 8 | children, 9 | dashPreview, 10 | }: { 11 | children: ReactNode; 12 | dashPreview: ReactNode; 13 | }) { 14 | return ( 15 | <> 16 | {dashPreview} 17 | {children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/whiteboard/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname, useRouter } from "next/navigation"; 4 | import { Whiteboard } from "@/components/Whiteboard"; 5 | import clsx from "clsx"; 6 | import styles from "./WhiteboardPage.module.css"; 7 | import { Header } from "@/components/Header/Header"; 8 | 9 | export default function WhiteboardPage({ 10 | params: { id }, 11 | }: { 12 | params: { id: string }; 13 | }) { 14 | const pathname = usePathname(); 15 | const onDashboard = pathname.endsWith("dashboard"); 16 | const router = useRouter(); 17 | return ( 18 | <> 19 |
25 | 26 | {onDashboard ? ( 27 |
33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/whiteboard/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /components/Cursors/Cursor.module.css: -------------------------------------------------------------------------------- 1 | .cursor { 2 | position: absolute; 3 | z-index: var(--z-above); 4 | top: 0; 5 | left: 0; 6 | pointer-events: none; 7 | transition: transform var(--transition-linear); 8 | user-select: none; 9 | } 10 | 11 | .pointer { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | } 16 | 17 | .name { 18 | position: absolute; 19 | top: var(--space-8); 20 | left: var(--space-8); 21 | overflow: hidden; 22 | padding: 0.375rem 0.75rem; 23 | border-radius: var(--radius-sm); 24 | font-size: var(--size-sm); 25 | font-weight: 500; 26 | white-space: nowrap; 27 | } 28 | -------------------------------------------------------------------------------- /components/Cursors/Cursor.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { ComponentProps, useMemo } from "react"; 3 | import { getContrastingColor } from "@/utils"; 4 | import styles from "./Cursor.module.css"; 5 | 6 | interface Props extends Omit, "color"> { 7 | color: string; 8 | name: string; 9 | x: number; 10 | y: number; 11 | } 12 | 13 | export function Cursor({ 14 | x, 15 | y, 16 | color, 17 | name, 18 | className, 19 | style, 20 | ...props 21 | }: Props) { 22 | const textColor = useMemo( 23 | () => (color ? getContrastingColor(color) : undefined), 24 | [color] 25 | ); 26 | 27 | return ( 28 |
33 | 40 | 44 | 45 |
52 | {name} 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /components/Cursors/Cursors.tsx: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useEffect } from "react"; 2 | import { useOthers, useUpdateMyPresence } from "@/liveblocks.config"; 3 | import { Cursor } from "./Cursor"; 4 | 5 | interface Props { 6 | // The element that's used for pointer events and scroll position 7 | element: MutableRefObject; 8 | } 9 | 10 | /** 11 | * This file shows you how to create a reusable live cursors component for your product. 12 | * The component takes a reference to another element ref `element` and renders 13 | * cursors according to the location and scroll position of this panel. 14 | */ 15 | export function Cursors({ element }: Props) { 16 | /** 17 | * useMyPresence returns a function to update the current user's presence. 18 | * updateMyPresence is different to the setState function returned by the useState hook from React. 19 | * You don't need to pass the full presence object to update it. 20 | * See https://liveblocks.io/docs/api-reference/liveblocks-react#useUpdateMyPresence for more information 21 | */ 22 | const updateMyPresence = useUpdateMyPresence(); 23 | 24 | /** 25 | * Return all the other users in the room and their presence (a cursor position in this case) 26 | */ 27 | const others = useOthers(); 28 | 29 | useEffect(() => { 30 | if (!element.current) { 31 | return; 32 | } 33 | 34 | // If element, add live cursor listeners 35 | const updateCursor = (event: PointerEvent) => { 36 | if (!element?.current) { 37 | return; 38 | } 39 | 40 | const { top, left } = element.current!.getBoundingClientRect(); 41 | 42 | const x = event.clientX - left + element.current!.scrollLeft; 43 | const y = event.clientY - top + element.current!.scrollTop; 44 | 45 | updateMyPresence({ 46 | cursor: { 47 | x: Math.round(x), 48 | y: Math.round(y), 49 | }, 50 | }); 51 | }; 52 | 53 | const removeCursor = () => { 54 | updateMyPresence({ 55 | cursor: null, 56 | }); 57 | }; 58 | 59 | element.current!.addEventListener("pointermove", updateCursor); 60 | element.current!.addEventListener("pointerleave", removeCursor); 61 | 62 | // Clean up event listeners 63 | const oldRef = element.current; 64 | return () => { 65 | if (!oldRef) { 66 | return; 67 | } 68 | oldRef.removeEventListener("pointermove", updateCursor); 69 | oldRef.removeEventListener("pointerleave", removeCursor); 70 | }; 71 | }, [updateMyPresence, element]); 72 | 73 | return ( 74 | <> 75 | { 76 | /** 77 | * Iterate over other users and display a cursor based on their presence 78 | */ 79 | others.map(({ connectionId, presence }) => { 80 | if (presence == null || presence.cursor == null) { 81 | return null; 82 | } 83 | 84 | return ( 85 | 94 | ); 95 | }) 96 | } 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /components/Cursors/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Cursors"; 2 | -------------------------------------------------------------------------------- /components/Dashboard/Dashboard.module.css: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | background: rgb(var(--color-gray-50)); 3 | } 4 | 5 | .dashboardHeader { 6 | font-size: var(--size-xl); 7 | font-weight: 600; 8 | margin: var(--space-10) 0; 9 | } 10 | 11 | .dashboardList { 12 | max-width: 800px; 13 | margin: 50px auto 0; 14 | display: flex; 15 | flex-direction: column; 16 | padding: var(--space-12); 17 | } 18 | -------------------------------------------------------------------------------- /components/Dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { DocumentRow } from "@/components/Dashboard/DocumentRow"; 2 | import styles from "./Dashboard.module.css"; 3 | 4 | const ids = [...Array(30).keys()]; 5 | 6 | export function Dashboard() { 7 | return ( 8 |
9 |
10 |
Documents
11 | {ids.map((id) => ( 12 | 13 | ))} 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /components/Dashboard/DocumentIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | export function DocumentIcon(props: ComponentProps<"svg">) { 4 | return ( 5 | 13 | 21 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/Dashboard/DocumentRow.module.css: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | place-items: center; 4 | position: relative; 5 | border: 1px solid var(--color-border); 6 | border-bottom: 0; 7 | background: var(--color-surface-elevated); 8 | cursor: pointer; 9 | } 10 | 11 | .row:first-of-type { 12 | border-top-left-radius: var(--radius); 13 | border-top-right-radius: var(--radius); 14 | } 15 | 16 | .row:last-of-type { 17 | border-bottom: 1px solid var(--color-border); 18 | border-bottom-left-radius: var(--radius); 19 | border-bottom-right-radius: var(--radius); 20 | } 21 | 22 | .container { 23 | display: grid; 24 | flex: 1 0 auto; 25 | width: 100%; 26 | padding: var(--space-8) var(--space-19) var(--space-8) var(--space-8); 27 | gap: var(--space-8); 28 | grid-template-columns: auto 1fr auto; 29 | outline: none; 30 | } 31 | 32 | .icon, 33 | .info { 34 | transition: opacity var(--transition); 35 | } 36 | 37 | .link:hover .icon, 38 | .link:hover .info, 39 | .link:focus-visible .icon, 40 | .link:focus-visible .info { 41 | opacity: var(--opacity-hover); 42 | } 43 | 44 | .icon { 45 | display: flex; 46 | width: 40px; 47 | height: 52px; 48 | border: 1px solid var(--color-border); 49 | background: var(--color-surface-elevated); 50 | border-radius: var(--radius-xs); 51 | box-shadow: var(--shadow-xs); 52 | place-content: center; 53 | place-items: center; 54 | } 55 | 56 | .info { 57 | display: flex; 58 | flex-direction: column; 59 | place-content: center; 60 | } 61 | 62 | .infoSkeleton { 63 | gap: var(--space-4); 64 | } 65 | 66 | .documentName { 67 | font-size: var(--size-sm); 68 | font-weight: 500; 69 | } 70 | 71 | .documentDate { 72 | color: var(--color-text-lighter); 73 | font-size: var(--size-xs); 74 | } 75 | 76 | .groups { 77 | display: inline-flex; 78 | margin-left: var(--space-3); 79 | gap: var(--space-3); 80 | } 81 | 82 | .group { 83 | position: relative; 84 | display: inline-block; 85 | padding: 0 var(--space-3); 86 | border-radius: 1em; 87 | color: var(--color-text-lighter); 88 | background: var(--color-surface); 89 | font-size: 0.8em; 90 | } 91 | 92 | .presence { 93 | display: flex; 94 | place-items: center; 95 | } 96 | 97 | .more { 98 | position: absolute; 99 | right: var(--space-10); 100 | } 101 | 102 | .morePopover { 103 | display: flex; 104 | min-width: 160px; 105 | max-width: 320px; 106 | flex-direction: column; 107 | padding: var(--space-3); 108 | } 109 | -------------------------------------------------------------------------------- /components/Dashboard/DocumentRow.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./DocumentRow.module.css"; 2 | import clsx from "clsx"; 3 | import { DocumentIcon } from "@/components/Dashboard/DocumentIcon"; 4 | import Link from "next/link"; 5 | 6 | export function DocumentRow({ id }: { id: number | string }) { 7 | return ( 8 | 9 |
10 |
11 | 12 |
13 |
14 | Untitled 15 | Edited just now 16 |
17 |
18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/Header/Header.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | position: fixed; 3 | top: 0; 4 | width: 100%; 5 | padding: var(--space-8); 6 | background: var(--color-surface-elevated); 7 | border-bottom: 1px solid var(--color-border); 8 | } 9 | -------------------------------------------------------------------------------- /components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "@/components/Logo"; 2 | import Link from "next/link"; 3 | import styles from "./Header.module.css"; 4 | 5 | export function Header() { 6 | return ( 7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /components/Logo/Logo.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | display: flex; 3 | font-size: var(--size-lg); 4 | font-weight: 700; 5 | place-items: center; 6 | } 7 | 8 | .mark { 9 | width: 1.35em; 10 | height: 1.35em; 11 | margin-right: 0.35em; 12 | } 13 | 14 | .wordmark { 15 | white-space: nowrap; 16 | } 17 | -------------------------------------------------------------------------------- /components/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { ComponentProps } from "react"; 3 | import styles from "./Logo.module.css"; 4 | 5 | export function Logo({ className, ...props }: ComponentProps<"div">) { 6 | return ( 7 |
8 | 14 | 20 | 21 | Dashboard 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/Logo/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Logo"; 2 | -------------------------------------------------------------------------------- /components/Whiteboard/Whiteboard.module.css: -------------------------------------------------------------------------------- 1 | .canvas { 2 | position: relative; 3 | overflow: hidden; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | .toolbar { 9 | position: absolute; 10 | z-index: var(--z-above); 11 | bottom: var(--space-10); 12 | left: 50%; 13 | display: flex; 14 | padding: var(--space-4); 15 | border: 1px solid var(--color-border); 16 | background: var(--color-surface-elevated); 17 | border-radius: var(--radius); 18 | box-shadow: var(--shadow-lg); 19 | transform: translateX(-50%); 20 | } 21 | 22 | .loading { 23 | position: absolute; 24 | display: flex; 25 | color: var(--color-text-lighter); 26 | inset: 0; 27 | place-content: center; 28 | place-items: center; 29 | } 30 | -------------------------------------------------------------------------------- /components/Whiteboard/Whiteboard.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { LiveMap, LiveObject, shallow } from "@liveblocks/client"; 3 | import { ClientSideSuspense } from "@liveblocks/react"; 4 | import { nanoid } from "nanoid"; 5 | import { 6 | ChangeEvent, 7 | ComponentProps, 8 | FocusEvent, 9 | PointerEvent, 10 | useRef, 11 | useState, 12 | } from "react"; 13 | import { PlusIcon, RedoIcon, UndoIcon } from "@/icons"; 14 | import { 15 | RoomProvider, 16 | useCanRedo, 17 | useCanUndo, 18 | useHistory, 19 | useMutation, 20 | useSelf, 21 | useStorage, 22 | } from "@/liveblocks.config"; 23 | import { Button } from "@/primitives/Button"; 24 | import { Spinner } from "@/primitives/Spinner"; 25 | import { Tooltip } from "@/primitives/Tooltip"; 26 | import { useBoundingClientRectRef } from "@/utils"; 27 | import { Cursors } from "../Cursors"; 28 | import { WhiteboardNote } from "./WhiteboardNote"; 29 | import styles from "./Whiteboard.module.css"; 30 | import { randomUser } from "@/utils/randomUser"; 31 | import { TooltipProvider } from "@radix-ui/react-tooltip"; 32 | 33 | /** 34 | * This file shows how to create a multiplayer canvas with draggable notes. 35 | * The notes allow you to add text, display who's currently editing them, and can be removed. 36 | * There's also a toolbar allowing you to undo/redo your actions and add more notes. 37 | */ 38 | 39 | export function Whiteboard({ roomId }: { roomId: string }) { 40 | const loading = ( 41 |
42 | 43 |
44 | ); 45 | 46 | return ( 47 | 48 | 53 | 54 | {() => } 55 | 56 | 57 | 58 | ); 59 | } 60 | 61 | // The main Liveblocks code, handling all events and note modifications 62 | function Canvas({ className, style, ...props }: ComponentProps<"div">) { 63 | // An array of every note id 64 | const noteIds: string[] = useStorage( 65 | (root) => Array.from(root.notes.keys()), 66 | shallow 67 | ); 68 | 69 | const currentUser = useSelf((me) => me.presence.info); 70 | 71 | const history = useHistory(); 72 | const canUndo = useCanUndo(); 73 | const canRedo = useCanRedo(); 74 | 75 | const canvasRef = useRef(null); 76 | const rectRef = useBoundingClientRectRef(canvasRef); 77 | 78 | const isReadOnly = useSelf((me) => me.isReadOnly); 79 | 80 | // Info about element being dragged 81 | const [isDragging, setIsDragging] = useState(false); 82 | const dragInfo = useRef<{ 83 | element: Element; 84 | noteId: string; 85 | offset: { x: number; y: number }; 86 | } | null>(); 87 | 88 | // Insert a new note onto the canvas 89 | const insertNote = useMutation(({ storage, self }) => { 90 | if (self.isReadOnly) { 91 | return; 92 | } 93 | 94 | const noteId = nanoid(); 95 | const note = new LiveObject({ 96 | x: getRandomInt(300), 97 | y: getRandomInt(300), 98 | text: "", 99 | selectedBy: null, 100 | id: noteId, 101 | }); 102 | storage.get("notes").set(noteId, note); 103 | }, []); 104 | 105 | // Delete a note 106 | const handleNoteDelete = useMutation(({ storage, self }, noteId) => { 107 | if (self.isReadOnly) { 108 | return; 109 | } 110 | 111 | storage.get("notes").delete(noteId); 112 | }, []); 113 | 114 | // Update a note, if it exists 115 | const handleNoteUpdate = useMutation(({ storage, self }, noteId, updates) => { 116 | if (self.isReadOnly) { 117 | return; 118 | } 119 | 120 | const note = storage.get("notes").get(noteId); 121 | if (note) { 122 | note.update(updates); 123 | } 124 | }, []); 125 | 126 | // On note pointer down, pause history, set dragged note 127 | function handleNotePointerDown( 128 | e: PointerEvent, 129 | noteId: string 130 | ) { 131 | history.pause(); 132 | e.stopPropagation(); 133 | const element = document.querySelector(`[data-note="${noteId}"]`); 134 | if (!element) { 135 | return; 136 | } 137 | 138 | // Get position of cursor on note, to use as an offset when moving notes 139 | const rect = element.getBoundingClientRect(); 140 | const offset = { 141 | x: e.clientX - rect.left, 142 | y: e.clientY - rect.top, 143 | }; 144 | 145 | dragInfo.current = { noteId, element, offset }; 146 | setIsDragging(true); 147 | document.documentElement.classList.add("grabbing"); 148 | } 149 | 150 | // On canvas pointer up, remove dragged element, resume history 151 | function handleCanvasPointerUp() { 152 | setIsDragging(false); 153 | dragInfo.current = null; 154 | document.documentElement.classList.remove("grabbing"); 155 | history.resume(); 156 | } 157 | 158 | // If dragging on canvas pointer move, move element and adjust for offset 159 | function handleCanvasPointerMove(e: PointerEvent) { 160 | e.preventDefault(); 161 | 162 | if (isDragging && dragInfo.current) { 163 | const { x, y } = dragInfo.current!.offset; 164 | const coords = { 165 | x: e.clientX - rectRef.current.x - x, 166 | y: e.clientY - rectRef.current.y - y, 167 | }; 168 | handleNoteUpdate(dragInfo.current!.noteId, coords); 169 | } 170 | } 171 | 172 | // When note text is changed, update the text and selected user on the LiveObject 173 | function handleNoteChange( 174 | e: ChangeEvent, 175 | noteId: string 176 | ) { 177 | handleNoteUpdate(noteId, { text: e.target.value, selectedBy: currentUser }); 178 | } 179 | 180 | // When note is focused, update the selected user LiveObject 181 | function handleNoteFocus(e: FocusEvent, noteId: string) { 182 | history.pause(); 183 | handleNoteUpdate(noteId, { selectedBy: currentUser }); 184 | } 185 | 186 | // When note is unfocused, remove the selected user on the LiveObject 187 | function handleNoteBlur(e: FocusEvent, noteId: string) { 188 | handleNoteUpdate(noteId, { selectedBy: null }); 189 | history.resume(); 190 | } 191 | 192 | return ( 193 |
201 | 202 | { 203 | /* 204 | * Iterate through each note in the LiveMap and render it as a note 205 | */ 206 | noteIds.map((id) => ( 207 | handleNoteBlur(e, id)} 212 | onChange={(e) => handleNoteChange(e, id)} 213 | onDelete={() => handleNoteDelete(id)} 214 | onFocus={(e) => handleNoteFocus(e, id)} 215 | onPointerDown={(e) => handleNotePointerDown(e, id)} 216 | /> 217 | )) 218 | } 219 | 220 | {!isReadOnly && ( 221 |
222 | 223 |
242 | )} 243 |
244 | ); 245 | } 246 | 247 | function getRandomInt(max: number) { 248 | return Math.floor(Math.random() * max); 249 | } 250 | -------------------------------------------------------------------------------- /components/Whiteboard/WhiteboardNote.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | width: auto; 4 | min-width: 250px; 5 | max-width: 400px; 6 | min-height: 100px; 7 | transition: transform var(--transition-linear); 8 | } 9 | 10 | .note { 11 | display: flex; 12 | overflow: hidden; 13 | flex-direction: column; 14 | padding: var(--space-5) var(--space-8) var(--space-7); 15 | border: 1px solid var(--color-border); 16 | background: var(--color-surface-elevated); 17 | border-radius: var(--radius); 18 | box-shadow: var(--shadow); 19 | gap: var(--space-5); 20 | } 21 | 22 | .header { 23 | display: flex; 24 | height: var(--space-13); 25 | flex: none; 26 | place-content: space-between; 27 | place-items: center; 28 | } 29 | 30 | .deleteButton { 31 | margin-left: calc(-1 * var(--space-3)); 32 | } 33 | 34 | .content { 35 | position: relative; 36 | flex: 1 0 auto; 37 | } 38 | 39 | .textAreaSize { 40 | overflow: hidden; 41 | width: 100%; 42 | max-width: 100%; 43 | visibility: hidden; 44 | white-space: pre-wrap; 45 | word-wrap: break-word; 46 | } 47 | 48 | .textArea { 49 | position: absolute; 50 | top: 0; 51 | left: 0; 52 | display: block; 53 | overflow: hidden; 54 | width: 100%; 55 | height: 100%; 56 | background: transparent; 57 | outline: none; 58 | resize: none; 59 | word-wrap: break-word; 60 | } 61 | 62 | .textArea::placeholder { 63 | color: var(--color-text-lightest); 64 | } 65 | -------------------------------------------------------------------------------- /components/Whiteboard/WhiteboardNote.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { 3 | ChangeEventHandler, 4 | ComponentProps, 5 | FocusEventHandler, 6 | KeyboardEvent, 7 | memo, 8 | PointerEventHandler, 9 | useCallback, 10 | useRef, 11 | } from "react"; 12 | import { CrossIcon } from "../../icons"; 13 | import { useStorage } from "../../liveblocks.config"; 14 | import { Avatar } from "../../primitives/Avatar"; 15 | import { Button } from "../../primitives/Button"; 16 | import styles from "./WhiteboardNote.module.css"; 17 | 18 | interface Props 19 | extends Omit< 20 | ComponentProps<"div">, 21 | "id" | "onBlur" | "onChange" | "onFocus" 22 | > { 23 | dragged: boolean; 24 | id: string; 25 | onBlur: FocusEventHandler; 26 | onChange: ChangeEventHandler; 27 | onDelete: () => void; 28 | onFocus: FocusEventHandler; 29 | onPointerDown: PointerEventHandler; 30 | } 31 | 32 | export const WhiteboardNote = memo( 33 | ({ 34 | id, 35 | dragged, 36 | onPointerDown, 37 | onDelete, 38 | onChange, 39 | onFocus, 40 | onBlur, 41 | style, 42 | className, 43 | ...props 44 | }: Props) => { 45 | const textAreaRef = useRef(null); 46 | const note = useStorage((root) => root.notes.get(id)); 47 | 48 | const handleDoubleClick = useCallback(() => { 49 | textAreaRef.current?.focus(); 50 | }, []); 51 | 52 | const handleKeyDown = useCallback( 53 | (event: KeyboardEvent) => { 54 | if (event.key === "Escape") { 55 | textAreaRef.current?.blur(); 56 | } 57 | }, 58 | [] 59 | ); 60 | 61 | if (!note) { 62 | return null; 63 | } 64 | 65 | const { x, y, text, selectedBy } = note; 66 | 67 | return ( 68 |
82 |
83 |
84 |
101 |
102 |
{text + " "}
103 |