├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── app ├── api │ └── image │ │ └── route.ts ├── default.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── page.module.css ├── page.tsx ├── upload │ └── page.tsx └── viewer │ ├── @uploadTray │ ├── (...)upload │ │ └── page.tsx │ └── default.tsx │ ├── layout.tsx │ └── page.tsx ├── components ├── FileList │ ├── File.tsx │ ├── FileList.tsx │ └── index.ts ├── FileUpload │ ├── FileUpload.tsx │ └── index.ts ├── Header │ └── Header.tsx ├── Loading │ ├── Loading.tsx │ └── index.ts └── Room │ ├── Room.tsx │ └── index.ts ├── icons ├── CrossIcon.tsx ├── DeleteIcon.tsx ├── ImageIcon.tsx ├── SpinnerIcon.tsx ├── UploadIcon.tsx └── index.ts ├── liveblocks.config.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── tailwind.config.js ├── 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 | 40 | -------------------------------------------------------------------------------- /.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/236485311-df7cbe18-3152-44a6-82cf-bdc32948673e.mp4 2 | 3 | ## Vercel Blob + Liveblocks demo 4 | 5 | This demo shows you how to use [Vercel Blob](https://vercel.com/docs/storage/vercel-blob) to upload images, with a [Liveblocks](https://liveblocks.io/) real-time collaborative app. 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 13 | variable 14 | - Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) 15 | -------------------------------------------------------------------------------- /app/api/image/route.ts: -------------------------------------------------------------------------------- 1 | import * as vercelBlob from "@vercel/blob"; 2 | import { NextResponse, NextRequest } from "next/server"; 3 | 4 | export const runtime = "edge"; 5 | 6 | export async function POST(request: NextRequest) { 7 | const { searchParams } = new URL(request.url); 8 | const id = searchParams.get("id"); 9 | const file = await request.blob(); 10 | 11 | if (!id || !file) { 12 | return NextResponse.json( 13 | { error: "File name or file not submitted" }, 14 | { status: 400 } 15 | ); 16 | } 17 | 18 | const blob = await vercelBlob.put(id, file, { access: "public" }); 19 | 20 | return NextResponse.json({ 21 | url: blob.url, 22 | }); 23 | } 24 | 25 | export async function DELETE(request: NextRequest) { 26 | const { searchParams } = new URL(request.url); 27 | const url = searchParams.get("url"); 28 | 29 | if (!url) { 30 | return NextResponse.json({ error: "No URL submitted" }, { status: 400 }); 31 | } 32 | 33 | const response = await vercelBlob.del(url); 34 | 35 | if (!response) { 36 | return NextResponse.json( 37 | { error: "Vercel Blob deletion error" }, 38 | { status: 500 } 39 | ); 40 | } 41 | 42 | // TODO 43 | return NextResponse.json({ success: true }); 44 | } 45 | -------------------------------------------------------------------------------- /app/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CTNicholas/collaborative-file-upload/3cc839da15ef26d9639d0e45951871b984718d3e/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | @layer base { 7 | html { 8 | font-family: "Inter FIX", Inter, system-ui, sans-serif; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Header } from "@/components/Header/Header"; 5 | import { Room } from "@/components/Room"; 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export default function Layout({ children }: { children: ReactNode }) { 10 | return ( 11 | 12 | 13 | 14 | Liveblocks 15 | 21 | 27 | 28 | 29 |
30 |
{children}
31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /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("/viewer"); 5 | } 6 | -------------------------------------------------------------------------------- /app/upload/page.tsx: -------------------------------------------------------------------------------- 1 | import { FileUpload } from "@/components/FileUpload"; 2 | 3 | export const metadata = { 4 | title: "Upload", 5 | }; 6 | 7 | export default function Home() { 8 | return ( 9 |
10 |
11 | 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/viewer/@uploadTray/(...)upload/page.tsx: -------------------------------------------------------------------------------- 1 | import { FileUpload } from "@/components/FileUpload"; 2 | 3 | export const metadata = { 4 | title: "Upload file", 5 | }; 6 | 7 | export default function UploadTray() { 8 | return ( 9 |
10 |
11 | 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/viewer/@uploadTray/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /app/viewer/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export const metadata = { 4 | title: "View files", 5 | }; 6 | 7 | export default function ViewerLayout({ 8 | children, 9 | uploadTray, 10 | }: { 11 | children: ReactNode; 12 | uploadTray: ReactNode; 13 | }) { 14 | return ( 15 | <> 16 | {children} 17 | {uploadTray} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/viewer/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FileList } from "@/components/FileList"; 4 | 5 | export default function ViewerPage() { 6 | return ( 7 | <> 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /components/FileList/File.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useStorage } from "@/liveblocks.config"; 2 | import clsx from "clsx"; 3 | import { DeleteIcon } from "@/icons/DeleteIcon"; 4 | import { SpinnerIcon } from "@/icons/SpinnerIcon"; 5 | import Image from "next/image"; 6 | import { useState } from "react"; 7 | 8 | export function File({ id }: { id: string }) { 9 | const file = useStorage((root) => root.files.get(id)); 10 | const [imageLoaded, setImageLoaded] = useState(false); 11 | 12 | const deleteFile = useMutation( 13 | async ({ storage }) => { 14 | const files = storage.get("files"); 15 | const file = files.get(id); 16 | 17 | if (!file) { 18 | return; 19 | } 20 | 21 | file.update({ state: "deleting" }); 22 | 23 | const response = await fetch(`/api/image?url=${file.get("url")}`, { 24 | method: "DELETE", 25 | }); 26 | 27 | if (!response.ok) { 28 | file.update({ state: "ready" }); 29 | return; 30 | } 31 | 32 | files.delete(id); 33 | }, 34 | [id] 35 | ); 36 | 37 | if (!file) { 38 | return null; 39 | } 40 | 41 | const { title, description, url, state } = file; 42 | 43 | if (state === "uploading") { 44 | return ( 45 |
  • 46 |
    47 |
    48 |
    49 |
    50 |
    51 |
  • 52 | ); 53 | } 54 | 55 | return ( 56 |
  • 61 | 66 | setImageLoaded(true)} 68 | src={url} 69 | alt={description} 70 | width="1000" 71 | height="1000" 72 | className={clsx( 73 | "block w-full h-full opacity-0 transition-opacity blur-lg", 74 | { 75 | "opacity-100 blur-none": imageLoaded, 76 | } 77 | )} 78 | /> 79 | 80 |
    81 |
    82 | 83 | {title} 84 | 85 |
    {description}
    86 |
    87 | {state === "ready" ? ( 88 | 95 | ) : ( 96 |
    97 | File under deletion 98 | 102 |
    103 | )} 104 |
    105 |
  • 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /components/FileList/FileList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ClientSideSuspense } from "@liveblocks/react"; 4 | import { useStorage } from "@/liveblocks.config"; 5 | import { shallow } from "@liveblocks/client"; 6 | import { File } from "./File"; 7 | import { Loading } from "@/components/Loading"; 8 | 9 | export function FileList() { 10 | return ( 11 |
    12 | }> 13 | {() => } 14 | 15 |
    16 | ); 17 | } 18 | 19 | function List() { 20 | // Creating a new array from a keys() iterator every time, so using shallow equality check 21 | const fileIds = useStorage((root) => [...root.files.keys()], shallow); 22 | 23 | return ( 24 |
    25 |
      26 | {fileIds.map((id) => ( 27 | 28 | ))} 29 |
    30 |
    31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /components/FileList/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./FileList"; 2 | -------------------------------------------------------------------------------- /components/FileUpload/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FormEvent, useMemo, useState } from "react"; 4 | import { useMutation } from "@/liveblocks.config"; 5 | import { nanoid } from "nanoid"; 6 | import { LiveObject } from "@liveblocks/client"; 7 | import { useRouter } from "next/navigation"; 8 | import { UploadIcon } from "@/icons/UploadIcon"; 9 | import { CrossIcon } from "@/icons/CrossIcon"; 10 | 11 | export function FileUpload() { 12 | const router = useRouter(); 13 | const [currentFile, setCurrentFile] = useState(null); 14 | const [currentName, setCurrentName] = useState(""); 15 | const [currentDescription, setCurrentDescription] = useState(""); 16 | 17 | const imageBlobUrl = useMemo( 18 | () => (currentFile ? URL.createObjectURL(currentFile) : ""), 19 | [currentFile] 20 | ); 21 | 22 | function resetForm() { 23 | setCurrentFile(null); 24 | setCurrentName(""); 25 | setCurrentDescription(""); 26 | } 27 | 28 | const handleSubmit = useMutation( 29 | async ({ storage }, e: FormEvent) => { 30 | e.preventDefault(); 31 | 32 | if (!currentFile) { 33 | return; 34 | } 35 | 36 | router.back(); 37 | resetForm(); 38 | 39 | const randomId = nanoid(); 40 | const fileExtension = currentFile.type.split("/")[1]; 41 | const fileName = `collaborative-upload-demo/${randomId}.${fileExtension}`; 42 | 43 | const files = storage.get("files"); 44 | files.set( 45 | fileName, 46 | new LiveObject({ 47 | title: "", 48 | description: "", 49 | url: "", 50 | state: "uploading", 51 | }) 52 | ); 53 | 54 | const response = await fetch( 55 | `/api/image?name=${currentName}&id=${fileName}`, 56 | { 57 | method: "POST", 58 | body: currentFile, 59 | } 60 | ); 61 | 62 | if (!response.ok) { 63 | files.delete(fileName); 64 | return; 65 | } 66 | 67 | const { url } = await response.json(); 68 | const file = files.get(fileName); 69 | 70 | if (!file) { 71 | // File LiveObject has been deleted during fetch call 72 | return; 73 | } 74 | 75 | file.update({ 76 | title: currentName, 77 | description: currentDescription, 78 | url: url, 79 | state: "ready", 80 | }); 81 | }, 82 | [currentFile, currentName, currentDescription, router, resetForm] 83 | ); 84 | 85 | return ( 86 |
    87 |
    88 |
    Upload image
    89 | router.back()}> 90 | 91 | 92 |
    93 |
    94 |
    179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /components/FileUpload/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./FileUpload"; 2 | -------------------------------------------------------------------------------- /components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | import { ImageIcon } from "@/icons/ImageIcon"; 6 | 7 | export function Header() { 8 | const onUploadPage = usePathname().startsWith("/upload"); 9 | 10 | return ( 11 |
    12 |
    13 | 14 | Collaborative photo gallery 15 | 16 | {!onUploadPage ? ( 17 | 21 | 22 | New image 23 | 24 | ) : null} 25 |
    26 | 32 | Made with Liveblocks 36 | 37 |
    38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /components/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | export function Loading() { 2 | return ( 3 |
    4 | Loading 9 |
    10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /components/Loading/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Loading"; 2 | -------------------------------------------------------------------------------- /components/Room/Room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | import { RoomProvider } from "@/liveblocks.config"; 5 | import { LiveMap } from "@liveblocks/client"; 6 | 7 | type Props = { 8 | children: ReactNode; 9 | }; 10 | 11 | export function Room({ children }: Props) { 12 | return ( 13 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /components/Room/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Room"; 2 | -------------------------------------------------------------------------------- /icons/CrossIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | type Props = { 4 | iconSize: "sm" | "md"; 5 | } & ComponentProps<"svg">; 6 | 7 | export function CrossIcon(props: Props) { 8 | const { iconSize, ...otherProps } = props; 9 | 10 | if (iconSize === "sm") { 11 | return ( 12 | 18 | 19 | 20 | ); 21 | } 22 | 23 | return ( 24 | 31 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /icons/DeleteIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | type Props = { 4 | iconSize: "sm" | "md"; 5 | } & ComponentProps<"svg">; 6 | 7 | export function DeleteIcon(props: Props) { 8 | const { iconSize, ...otherProps } = props; 9 | 10 | if (iconSize === "sm") { 11 | return ( 12 | 13 | 18 | 19 | ); 20 | } 21 | 22 | return ( 23 | 24 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /icons/ImageIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | type Props = { 4 | iconSize: "sm" | "md"; 5 | } & ComponentProps<"svg">; 6 | 7 | export function ImageIcon(props: Props) { 8 | const { iconSize, ...otherProps } = props; 9 | 10 | if (iconSize === "sm") { 11 | return ( 12 | 13 | 18 | 19 | ); 20 | } 21 | 22 | return ( 23 | 24 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /icons/SpinnerIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | type Props = { 4 | iconSize: "sm" | "md"; 5 | } & ComponentProps<"svg">; 6 | 7 | export function SpinnerIcon(props: Props) { 8 | const { iconSize, ...otherProps } = props; 9 | 10 | if (iconSize === "sm") { 11 | return ( 12 | 13 | 21 | 26 | 27 | ); 28 | } 29 | 30 | return ( 31 | 32 | 40 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /icons/UploadIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | type Props = { 4 | iconSize: "sm" | "md"; 5 | } & ComponentProps<"svg">; 6 | 7 | export function UploadIcon(props: Props) { 8 | const { iconSize, ...otherProps } = props; 9 | 10 | if (iconSize === "sm") { 11 | return ( 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | return ( 20 | 27 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Plus"; 2 | export * from "./Cross"; 3 | export * from "./Undo"; 4 | export * from "./Redo"; 5 | -------------------------------------------------------------------------------- /liveblocks.config.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createClient, LiveMap, LiveObject } from "@liveblocks/client"; 4 | import { createRoomContext } from "@liveblocks/react"; 5 | 6 | const client = createClient({ 7 | publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY as string, 8 | throttle: 16, 9 | }); 10 | 11 | // Presence represents the properties that will exist on every User in the Room 12 | // and that will automatically be kept in sync. Accessible through the 13 | // `user.presence` property. Must be JSON-serializable. 14 | type Presence = {}; 15 | 16 | type File = LiveObject<{ 17 | title: string; 18 | description: string; 19 | url: string; 20 | state: "uploading" | "ready" | "deleting"; 21 | }>; 22 | 23 | type Files = LiveMap; 24 | 25 | // Optionally, Storage represents the shared document that persists in the 26 | // Room, even after all Users leave. Fields under Storage typically are 27 | // LiveList, LiveMap, LiveObject instances, for which updates are 28 | // automatically persisted and synced to all connected clients. 29 | type Storage = { 30 | files: Files; 31 | }; 32 | 33 | // Optionally, UserMeta represents static/readonly metadata on each User, as 34 | // provided by your own custom auth backend (if used). Useful for data that 35 | // will not change during a session, like a User's name or avatar. 36 | export type UserMeta = {}; 37 | 38 | // Optionally, the type of custom events broadcast and listened to in this 39 | // room. Must be JSON-serializable. 40 | type RoomEvent = {}; 41 | 42 | export const { 43 | suspense: { RoomProvider, useMutation, useStorage }, 44 | /* ...all the other hooks you’re using... */ 45 | } = createRoomContext(client); 46 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("next").NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | appDir: true, 6 | }, 7 | images: { 8 | remotePatterns: [ 9 | { 10 | protocol: "https", 11 | hostname: "public.blob.vercel-storage.com", 12 | }, 13 | ], 14 | }, 15 | }; 16 | 17 | module.exports = nextConfig; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liveblocks-nextjs-13.3-routes", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@liveblocks/client": "^1.0.2", 13 | "@liveblocks/node": "^1.0.2", 14 | "@liveblocks/react": "^1.0.2", 15 | "@radix-ui/react-tooltip": "^1.0.5", 16 | "@types/node": "18.15.11", 17 | "@types/react": "18.0.33", 18 | "@types/react-dom": "18.0.11", 19 | "@vercel/blob": "^0.8.1", 20 | "clsx": "^1.2.1", 21 | "nanoid": "^4.0.2", 22 | "next": "13.4.1", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "typescript": "5.0.4" 26 | }, 27 | "devDependencies": { 28 | "@typescript-eslint/eslint-plugin": "^5.57.1", 29 | "@typescript-eslint/parser": "^5.57.1", 30 | "autoprefixer": "^10.4.14", 31 | "eslint": "^8.38.0", 32 | "eslint-config-next": "^13.3.0", 33 | "eslint-config-prettier": "^8.8.0", 34 | "eslint-plugin-prettier": "^4.2.1", 35 | "postcss": "^8.4.23", 36 | "tailwindcss": "^3.3.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("tailwindcss").Config} */ 2 | module.exports = { 3 | content: [ 4 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./primitives/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | sans: ["Inter, sans-serif"], 11 | }, 12 | }, 13 | plugins: [], 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "downlevelIteration": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ], 27 | "paths": { 28 | "@/*": [ 29 | "./*" 30 | ] 31 | } 32 | }, 33 | "include": [ 34 | "next-env.d.ts", 35 | "**/*.ts", 36 | "**/*.tsx", 37 | ".next/types/**/*.ts" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /utils/capitalize.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(string: string) { 2 | return string.charAt(0).toUpperCase() + string.slice(1); 3 | } 4 | -------------------------------------------------------------------------------- /utils/getContrastingColor.ts: -------------------------------------------------------------------------------- 1 | export function getContrastingColor(col: string) { 2 | if (typeof window === "undefined") { 3 | return; 4 | } 5 | const useBlack = getColor(hexToRgb(standardizeColor(col))); 6 | return useBlack ? "#000000" : "#ffffff"; 7 | } 8 | 9 | type RGB = { 10 | r: number; 11 | g: number; 12 | b: number; 13 | } | null; 14 | 15 | function getColor(rgb: RGB) { 16 | if (!rgb) { 17 | return; 18 | } 19 | 20 | const { r, g, b } = rgb; 21 | if (r && g && b) { 22 | const isLight = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255; 23 | return isLight < 0.5; 24 | } 25 | return false; 26 | } 27 | 28 | function standardizeColor(str: string): string { 29 | const ctx = document.createElement("canvas").getContext("2d"); 30 | if (!ctx) { 31 | return ""; 32 | } 33 | 34 | ctx.fillStyle = str; 35 | return ctx.fillStyle; 36 | } 37 | 38 | function hexToRgb(hex: string): RGB { 39 | // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") 40 | const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; 41 | hex = hex.replace(shorthandRegex, function (m, r, g, b) { 42 | return r + r + g + g + b + b; 43 | }); 44 | 45 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 46 | return result 47 | ? { 48 | r: parseInt(result[1], 16), 49 | g: parseInt(result[2], 16), 50 | b: parseInt(result[3], 16), 51 | } 52 | : null; 53 | } 54 | -------------------------------------------------------------------------------- /utils/getInitials.ts: -------------------------------------------------------------------------------- 1 | export function getInitials(name: string) { 2 | const initials = name.replace(/[^a-zA-Z- ]/g, "").match(/\b\w/g); 3 | 4 | return initials 5 | ? initials.map((initial) => initial.toUpperCase()).join("") 6 | : ""; 7 | } 8 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./capitalize"; 2 | export * from "./getContrastingColor"; 3 | export * from "./getInitials"; 4 | export * from "./useBoundingClientRectRef"; 5 | export * from "./normalizeTrailingSlash"; 6 | -------------------------------------------------------------------------------- /utils/normalizeTrailingSlash.ts: -------------------------------------------------------------------------------- 1 | export function normalizeTrailingSlash(string: string) { 2 | return string.replace(/\/$/, ""); 3 | } 4 | -------------------------------------------------------------------------------- /utils/randomUser.ts: -------------------------------------------------------------------------------- 1 | const NAMES = [ 2 | "Charlie Layne", 3 | "Mislav Abha", 4 | "Tatum Paolo", 5 | "Anjali Wanda", 6 | "Jody Hekla", 7 | "Emil Joyce", 8 | "Jory Quispe", 9 | "Quinn Elton", 10 | ]; 11 | 12 | const COLORS = [ 13 | "#E57373", 14 | "#9575CD", 15 | "#4FC3F7", 16 | "#81C784", 17 | "#FFF176", 18 | "#FF8A65", 19 | "#F06292", 20 | "#7986CB", 21 | ]; 22 | 23 | export function randomUser() { 24 | return { 25 | name: NAMES[Math.floor(Math.random() * NAMES.length)], 26 | color: COLORS[Math.floor(Math.random() * COLORS.length)], 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /utils/useBoundingClientRectRef.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useEffect, useRef } from "react"; 2 | 3 | const initialRect = { 4 | x: 0, 5 | y: 0, 6 | height: 0, 7 | width: 0, 8 | top: 0, 9 | right: 0, 10 | bottom: 0, 11 | left: 0, 12 | toJSON: () => "", 13 | }; 14 | 15 | /** 16 | * Returns a ref containing the results of `getBoundingClientRect` for `ref` 17 | * Updates on window changes 18 | */ 19 | export function useBoundingClientRectRef( 20 | ref: MutableRefObject 21 | ) { 22 | const rectRef = useRef(initialRect); 23 | 24 | useEffect(() => { 25 | const updateRect = () => { 26 | if (!(ref?.current instanceof Element)) { 27 | return; 28 | } 29 | rectRef.current = ref.current.getBoundingClientRect(); 30 | }; 31 | 32 | window.addEventListener("resize", updateRect); 33 | window.addEventListener("orientationchange", updateRect); 34 | updateRect(); 35 | 36 | return () => { 37 | window.removeEventListener("resize", updateRect); 38 | window.removeEventListener("orientationchange", updateRect); 39 | }; 40 | }, [ref]); 41 | 42 | return rectRef; 43 | } 44 | --------------------------------------------------------------------------------