├── .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 |
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 |
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 |
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 |
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 |
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 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/components/Loading/Loading.tsx:
--------------------------------------------------------------------------------
1 | export function Loading() {
2 | return (
3 |
4 |

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 |
20 | );
21 | }
22 |
23 | return (
24 |
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 |
19 | );
20 | }
21 |
22 | return (
23 |
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 |
19 | );
20 | }
21 |
22 | return (
23 |
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 |
27 | );
28 | }
29 |
30 | return (
31 |
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 |
16 | );
17 | }
18 |
19 | return (
20 |
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 |
--------------------------------------------------------------------------------