├── .dockerignore ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── app ├── components │ ├── Confetti.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── progress.tsx │ │ └── separator.tsx ├── framework │ └── shadcn.ts ├── root.tsx ├── routes │ ├── _index.ts │ ├── healthz.ts │ ├── upload.advanced.tsx │ ├── upload.basic.tsx │ ├── upload.done.tsx │ ├── upload.progress.$uploadId.ts │ └── upload.tsx ├── styles │ └── globals.css └── utils │ ├── UploadEventBus.server.ts │ ├── confetti.server.ts │ ├── createObservableFileUploadHandler.server.ts │ ├── misc.server.ts │ └── useUploadProgress.ts ├── assets ├── ad6e4b67-8ff2-4e8a-b520-65a2cf74fa67.gif └── ad6e4b67-8ff2-4e8a-b520-65a2cf74fa67.png ├── biome.json ├── bun.lockb ├── components.json ├── devbox.json ├── devbox.lock ├── env.d.ts ├── fly.toml ├── package.json ├── postcss.config.mjs ├── public └── .gitkeep ├── renovate.json ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | /uploads 7 | .env 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | /uploads 7 | .env 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "editor.formatOnSave": true 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust BUN_VERSION as desired 4 | ARG BUN_VERSION=1.0.14 5 | FROM oven/bun:${BUN_VERSION} as base 6 | 7 | LABEL fly_launch_runtime="Remix" 8 | 9 | # Remix app lives here 10 | WORKDIR /app 11 | 12 | # Set production environment 13 | ENV NODE_ENV="production" 14 | 15 | # Throw-away build stage to reduce size of final image 16 | FROM base as build 17 | 18 | # Configure Node.js package repo 19 | RUN apt update && \ 20 | apt install -y ca-certificates curl gnupg && \ 21 | mkdir -p /etc/apt/keyrings && \ 22 | curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg 23 | 24 | RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list 25 | 26 | # Install packages needed to build node modules 27 | RUN apt-get update -qq && \ 28 | apt-get install -y build-essential pkg-config python-is-python3 nodejs 29 | 30 | # Install node modules 31 | COPY --link bun.lockb package.json ./ 32 | RUN bun install 33 | 34 | # Copy application code 35 | COPY --link . . 36 | 37 | # Build application 38 | RUN bun run build 39 | 40 | # Remove development dependencies 41 | RUN rm -rf node_modules && \ 42 | bun install --ci 43 | 44 | # Uncomment when bun is Remix-ready 45 | # # Final stage for app image 46 | # FROM base 47 | 48 | # # Copy built application 49 | # COPY --from=build /app /app 50 | 51 | # Start the server by default, this can be overwritten at runtime 52 | EXPOSE 3000 53 | CMD [ "bun", "run", "start" ] 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-observable-file-upload-demo 2 | 3 | ![Screenshot of the demo application with a file upload which visualizes the status with a progress bar](assets/ad6e4b67-8ff2-4e8a-b520-65a2cf74fa67.gif) 4 | 5 | This repository contains a demo application which showcases user file uploads with real-time progress tracking: 6 | 7 | - Article: [Progressively Enhanced File Uploads with Remix](https://andrekoenig.de/articles/progressively-enhanced-file-uploads-remix) 8 | - Demo: [https://demo-remix-observable-file-upload.fly.dev/](https://demo-remix-observable-file-upload.fly.dev/) 9 | 10 | ## License 11 | 12 | `remix-observable-file-upload-demo` is open-source software released under the MIT License. 13 | -------------------------------------------------------------------------------- /app/components/Confetti.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import { Index as ConfettiShower } from "confetti-react"; 15 | import { ClientOnly } from "remix-utils/client-only"; 16 | 17 | export function Confetti({ id }: { id?: string | null }) { 18 | if (!id) return null; 19 | 20 | return ( 21 | 22 | {() => ( 23 | 31 | )} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "~/framework/shadcn"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | }, 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )); 33 | Alert.displayName = "Alert"; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )); 45 | AlertTitle.displayName = "AlertTitle"; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )); 57 | AlertDescription.displayName = "AlertDescription"; 58 | 59 | export { Alert, AlertTitle, AlertDescription }; 60 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "~/framework/shadcn"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /app/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "~/framework/shadcn"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )); 42 | CardTitle.displayName = "CardTitle"; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )); 54 | CardDescription.displayName = "CardDescription"; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )); 62 | CardContent.displayName = "CardContent"; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = "CardFooter"; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /app/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "~/framework/shadcn"; 5 | 6 | const Progress = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, value, ...props }, ref) => ( 10 | 18 | 22 | 23 | )); 24 | Progress.displayName = ProgressPrimitive.Root.displayName; 25 | 26 | export { Progress }; 27 | -------------------------------------------------------------------------------- /app/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "~/framework/shadcn"; 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref, 13 | ) => ( 14 | 25 | ), 26 | ); 27 | Separator.displayName = SeparatorPrimitive.Root.displayName; 28 | 29 | export { Separator }; 30 | -------------------------------------------------------------------------------- /app/framework/shadcn.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import { type ClassValue, clsx } from "clsx"; 15 | import { twMerge } from "tailwind-merge"; 16 | 17 | export function cn(...inputs: ClassValue[]) { 18 | return twMerge(clsx(inputs)); 19 | } 20 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-upload-progress-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import type { LoaderFunctionArgs } from "@remix-run/node"; 15 | 16 | import "~/styles/globals.css"; 17 | 18 | import { 19 | Link, 20 | Links, 21 | LiveReload, 22 | Meta, 23 | Outlet, 24 | Scripts, 25 | ScrollRestoration, 26 | useLoaderData, 27 | } from "@remix-run/react"; 28 | 29 | import { json } from "@remix-run/node"; 30 | 31 | import { Confetti } from "~/components/Confetti.tsx"; 32 | import { getConfetti } from "./utils/confetti.server.ts"; 33 | import { combineHeaders } from "./utils/misc.server.ts"; 34 | 35 | export function loader({ request }: LoaderFunctionArgs) { 36 | const { confettiId, headers: confettiHeaders } = getConfetti(request); 37 | 38 | return json( 39 | { 40 | confettiId, 41 | }, 42 | { 43 | headers: combineHeaders(confettiHeaders), 44 | }, 45 | ); 46 | } 47 | 48 | export default function App() { 49 | const loaderData = useLoaderData(); 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | 62 | 63 | 64 |
65 | 66 |
67 | 68 | 69 | 70 |
71 | 72 | Made by André König 73 | 74 |
75 | 76 | 77 | 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /app/routes/_index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import { redirect } from "@remix-run/node"; 15 | 16 | export function loader() { 17 | return redirect("/upload/basic"); 18 | } 19 | -------------------------------------------------------------------------------- /app/routes/healthz.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | export function loader() { 15 | return new Response(); 16 | } 17 | -------------------------------------------------------------------------------- /app/routes/upload.advanced.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import type { ActionFunctionArgs, MetaFunction } from "@remix-run/node"; 15 | 16 | import { FileIcon, InfoCircledIcon, UploadIcon } from "@radix-ui/react-icons"; 17 | import { json, unstable_parseMultipartFormData } from "@remix-run/node"; 18 | import { 19 | Form, 20 | Link, 21 | useLoaderData, 22 | useResolvedPath, 23 | useSubmit, 24 | } from "@remix-run/react"; 25 | 26 | import { nanoid } from "nanoid"; 27 | import { Card } from "~/components/ui/card.tsx"; 28 | import { Progress } from "~/components/ui/progress.tsx"; 29 | import { uploadEventBus } from "~/utils/UploadEventBus.server.ts"; 30 | import { redirectWithConfetti } from "~/utils/confetti.server.ts"; 31 | import { createObservableFileUploadHandler } from "~/utils/createObservableFileUploadHandler.server.ts"; 32 | import { useUploadProgress } from "~/utils/useUploadProgress.ts"; 33 | 34 | type UploadProgressEvent = Readonly<{ 35 | uploadId: string; 36 | name: string; 37 | filename: string; 38 | filesizeInKilobytes: number; 39 | uploadedKilobytes: number; 40 | percentageStatus: number; 41 | remainingDurationInSeconds: number; 42 | }>; 43 | 44 | export const meta: MetaFunction = () => [ 45 | { 46 | title: "Advanced Example", 47 | }, 48 | ]; 49 | 50 | export function loader() { 51 | const uploadId = nanoid(); 52 | 53 | return json({ uploadId }); 54 | } 55 | 56 | export async function action({ request }: ActionFunctionArgs) { 57 | const start = Date.now(); 58 | 59 | const maxPartSize = 100_000_000; // 100 MB 60 | 61 | const url = new URL(request.url); 62 | const uploadId = url.searchParams.get("uploadId"); 63 | 64 | if (!uploadId) { 65 | throw new Response(null, { 66 | status: 400, 67 | statusText: "Upload ID is missing.", 68 | }); 69 | } 70 | 71 | // Get the overall filesize of the uploadable file. 72 | const filesize = Number(request.headers.get("Content-Length")); 73 | 74 | if (filesize > maxPartSize) { 75 | throw new Response(null, { 76 | status: 400, 77 | statusText: "File size exceeded", 78 | }); 79 | } 80 | 81 | const filesizeInKilobytes = Math.floor(filesize / 1024); 82 | 83 | const observableFileUploadHandler = createObservableFileUploadHandler({ 84 | avoidFileConflicts: true, 85 | maxPartSize, 86 | onProgress({ name, filename, uploadedBytes }) { 87 | const elapsedMilliseconds = Date.now() - start; 88 | 89 | const averageSpeed = uploadedBytes / elapsedMilliseconds; 90 | const remainingBytes = filesize - uploadedBytes; 91 | const remainingDurationInMilliseconds = remainingBytes / averageSpeed; 92 | 93 | uploadEventBus.emit({ 94 | uploadId, 95 | name, 96 | filename, 97 | filesizeInKilobytes, 98 | remainingDurationInSeconds: Math.floor( 99 | remainingDurationInMilliseconds / 1000, 100 | ), 101 | uploadedKilobytes: Math.floor(uploadedBytes / 1024), 102 | percentageStatus: Math.floor((uploadedBytes * 100) / filesize), 103 | }); 104 | }, 105 | onDone({ name, filename }) { 106 | uploadEventBus.emit({ 107 | uploadId, 108 | name, 109 | filename, 110 | filesizeInKilobytes, 111 | remainingDurationInSeconds: 0, 112 | uploadedKilobytes: filesizeInKilobytes, 113 | percentageStatus: 100, 114 | }); 115 | }, 116 | }); 117 | 118 | await unstable_parseMultipartFormData(request, observableFileUploadHandler); 119 | 120 | return redirectWithConfetti("/upload/done"); 121 | } 122 | 123 | export default function AdvancedExample() { 124 | const submit = useSubmit(); 125 | const loaderData = useLoaderData(); 126 | const currentPath = useResolvedPath("."); 127 | 128 | const progress = useUploadProgress(loaderData.uploadId); 129 | 130 | return ( 131 |
132 |
133 |

Advanced Example

134 |

135 | This example showcases an advanced implementation of an observable 136 | file upload. It includes everything that the{" "} 137 | 138 | basic example 139 | {" "} 140 | offers, and it also provides additional features such as displaying 141 | the uploaded bytes and estimating the remaining upload time. 142 |

143 |
144 | 145 | 146 |
{ 152 | submit(event.currentTarget); 153 | }} 154 | > 155 | 170 | 171 |

172 | 173 | max. 100 MB (configurable via{" "} 174 | 178 | maxPartSize 179 | 180 | ) 181 | 182 |

183 | 184 | {progress?.success && progress.event ? ( 185 |
186 |
187 | 188 |
189 |

190 | {progress.event.filename} 191 |

192 |
193 |

194 | {progress.event.uploadedKilobytes} KB /{" "} 195 | {progress.event.filesizeInKilobytes} KB ·{" "} 196 | {progress.event.remainingDurationInSeconds} seconds left 197 |

198 |

{progress.event.percentageStatus}%

199 |
200 |
201 |
202 | 203 |
204 | ) : null} 205 |
206 |
207 | 208 |

209 | 210 | Although the uploaded files are deleted after some time, please refrain 211 | from uploading any sensitive files here. 212 |

213 |
214 | ); 215 | } 216 | -------------------------------------------------------------------------------- /app/routes/upload.basic.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import type { ActionFunctionArgs, MetaFunction } from "@remix-run/node"; 15 | 16 | import { InfoCircledIcon } from "@radix-ui/react-icons"; 17 | import { json, unstable_parseMultipartFormData } from "@remix-run/node"; 18 | import { Form, Link, useLoaderData, useResolvedPath } from "@remix-run/react"; 19 | import { nanoid } from "nanoid"; 20 | import { Button } from "~/components/ui/button.tsx"; 21 | import { Card } from "~/components/ui/card.tsx"; 22 | import { Progress } from "~/components/ui/progress.tsx"; 23 | import { uploadEventBus } from "~/utils/UploadEventBus.server.ts"; 24 | import { redirectWithConfetti } from "~/utils/confetti.server.ts"; 25 | import { createObservableFileUploadHandler } from "~/utils/createObservableFileUploadHandler.server.ts"; 26 | import { useUploadProgress } from "~/utils/useUploadProgress.ts"; 27 | 28 | type UploadProgressEvent = Readonly<{ 29 | uploadId: string; 30 | name: string; 31 | filename: string; 32 | filesize: number; 33 | uploadedBytes: number; 34 | percentageStatus: number; 35 | }>; 36 | 37 | export const meta: MetaFunction = () => [ 38 | { 39 | title: "Basic Example", 40 | }, 41 | ]; 42 | 43 | export function loader() { 44 | const uploadId = nanoid(); 45 | 46 | return json({ uploadId }); 47 | } 48 | 49 | export async function action({ request }: ActionFunctionArgs) { 50 | const url = new URL(request.url); 51 | const uploadId = url.searchParams.get("uploadId"); 52 | 53 | const maxPartSize = 100_000_000; // 100 MB 54 | 55 | if (!uploadId) { 56 | throw new Response(null, { 57 | status: 400, 58 | statusText: "Upload ID is missing.", 59 | }); 60 | } 61 | 62 | // Get the overall filesize of the uploadable file. 63 | const filesize = Number(request.headers.get("Content-Length")); 64 | 65 | if (filesize > maxPartSize) { 66 | throw new Response(null, { 67 | status: 400, 68 | statusText: "File size exceeded", 69 | }); 70 | } 71 | 72 | const observableFileUploadHandler = createObservableFileUploadHandler({ 73 | avoidFileConflicts: true, 74 | maxPartSize, 75 | onProgress({ name, filename, uploadedBytes }) { 76 | uploadEventBus.emit({ 77 | uploadId, 78 | name, 79 | filename, 80 | filesize, 81 | uploadedBytes, 82 | percentageStatus: Math.floor((uploadedBytes * 100) / filesize), 83 | }); 84 | }, 85 | onDone({ name, filename, uploadedBytes }) { 86 | uploadEventBus.emit({ 87 | uploadId, 88 | name, 89 | filename, 90 | filesize, 91 | uploadedBytes, 92 | percentageStatus: 100, 93 | }); 94 | }, 95 | }); 96 | 97 | await unstable_parseMultipartFormData(request, observableFileUploadHandler); 98 | 99 | return redirectWithConfetti("/upload/done"); 100 | } 101 | 102 | export default function BasicExample() { 103 | const loaderData = useLoaderData(); 104 | const currentPath = useResolvedPath("."); 105 | 106 | const progress = useUploadProgress(loaderData.uploadId); 107 | 108 | return ( 109 |
110 |
111 |

Basic Example

112 |

113 | This example demonstrates a basic implementation of an observable file 114 | upload by utilizing a file input field and an action. Although the 115 | client implementation is straightforward, the example streams the 116 | upload progress to the client via{" "} 117 | 121 | SSE 122 | {" "} 123 | and displays it as a progress bar. 124 |

125 |
126 | 127 | 128 |
134 | 135 | 136 | 137 | 138 |

139 | 140 | max. 100 MB (configurable via{" "} 141 | 145 | maxPartSize 146 | 147 | ) 148 | 149 |

150 | 151 | {progress?.success && progress.event ? ( 152 |
153 | 154 |

155 | {progress.event.percentageStatus}% ·{" "} 156 | {progress.event.uploadedBytes} / {progress.event.filesize} bytes 157 | transferred 158 |

159 |
160 | ) : null} 161 |
162 |
163 | 164 |

165 | 166 | Although the uploaded files are deleted after some time, please refrain 167 | from uploading any sensitive files here. 168 |

169 |
170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /app/routes/upload.done.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import type { MetaFunction } from "@remix-run/node"; 15 | 16 | export const meta: MetaFunction = () => [ 17 | { 18 | title: "🎉Successful upload!", 19 | }, 20 | ]; 21 | 22 | export default function UploadDone() { 23 | return ( 24 |
25 |

🎉

26 |

Hooray!

27 |

28 | Your file upload was successful. 29 |

30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/routes/upload.progress.$uploadId.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import type { LoaderFunctionArgs } from "@remix-run/node"; 15 | import type { UploadEvent } from "~/utils/UploadEventBus.server.ts"; 16 | 17 | import { eventStream } from "remix-utils/sse/server"; 18 | import { uploadEventBus } from "~/utils/UploadEventBus.server.ts"; 19 | 20 | export async function loader({ request, params }: LoaderFunctionArgs) { 21 | const uploadId = params.uploadId; 22 | 23 | if (!uploadId) { 24 | throw new Response(null, { 25 | status: 400, 26 | statusText: "Upload ID is missing.", 27 | }); 28 | } 29 | 30 | return eventStream(request.signal, (send) => { 31 | const handle = (event: UploadEvent) => { 32 | send({ 33 | event: event.uploadId, 34 | data: JSON.stringify(event), 35 | }); 36 | }; 37 | 38 | uploadEventBus.addListener(uploadId, handle); 39 | 40 | return () => { 41 | uploadEventBus.removeListener(uploadId, handle); 42 | }; 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /app/routes/upload.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import { 15 | ExclamationTriangleIcon, 16 | FileTextIcon, 17 | GitHubLogoIcon, 18 | } from "@radix-ui/react-icons"; 19 | import { Link, NavLink, Outlet } from "@remix-run/react"; 20 | import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert.tsx"; 21 | import { Separator } from "~/components/ui/separator.tsx"; 22 | import { cn } from "~/framework/shadcn.ts"; 23 | 24 | export default function Index() { 25 | return ( 26 |
27 |
28 |

29 | Remix Observable Uploads 30 |

31 |
    32 |
  • 33 | 37 | Repository 38 | 39 |
  • 40 |
  • 41 | 45 | Docs 46 | 47 |
  • 48 |
49 |
50 | 51 | 52 |
53 | 95 |
96 | 97 |
98 |
99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /app/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /app/utils/UploadEventBus.server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import { EventEmitter } from "events"; 15 | 16 | export type UploadEvent = Readonly<{ 17 | uploadId: string; 18 | }>; 19 | 20 | class UploadEventBus { 21 | private readonly bus = new EventEmitter(); 22 | 23 | addListener(uploadId: string, listener: (event: T) => void) { 24 | this.bus.addListener(uploadId, listener); 25 | } 26 | 27 | removeListener(uploadId: string, listener: (event: T) => void) { 28 | this.bus.removeListener(uploadId, listener); 29 | } 30 | 31 | emit(event: T) { 32 | this.bus.emit(event.uploadId, event); 33 | } 34 | } 35 | 36 | export const uploadEventBus = new UploadEventBus(); 37 | -------------------------------------------------------------------------------- /app/utils/confetti.server.ts: -------------------------------------------------------------------------------- 1 | // Borrowed from [The Epic Stack](https://github.com/epicweb-dev/epic-stack) by [Kent C. Dodds](https://kentcdodds.com/). 2 | // https://github.com/kentcdodds/epic-stack-example-confetti/blob/768051d3fe07a9e981b5eb04abf928175adb93eb/app/utils/confetti.server.ts 3 | 4 | import { redirect } from "@remix-run/node"; 5 | import * as cookie from "cookie"; 6 | import { combineHeaders } from "./misc.server.ts"; 7 | 8 | const cookieName = "en_confetti"; 9 | 10 | export function getConfetti(request: Request) { 11 | const cookieHeader = request.headers.get("cookie"); 12 | const confettiId = cookieHeader 13 | ? cookie.parse(cookieHeader)[cookieName] 14 | : null; 15 | return { 16 | confettiId, 17 | headers: confettiId ? createConfettiHeaders(null) : null, 18 | }; 19 | } 20 | 21 | /** 22 | * This defaults the value to something reasonable if you want to show confetti. 23 | * If you want to clear the cookie, pass null and it will make a set-cookie 24 | * header that will delete the cookie 25 | * 26 | * @param value the value for the cookie in the set-cookie header 27 | * @returns Headers with a set-cookie header set to the value 28 | */ 29 | export function createConfettiHeaders( 30 | value: string | null = String(Date.now()), 31 | ) { 32 | return new Headers({ 33 | "set-cookie": cookie.serialize(cookieName, value ? value : "", { 34 | path: "/", 35 | maxAge: value ? 60 : -1, 36 | }), 37 | }); 38 | } 39 | 40 | export async function redirectWithConfetti(url: string, init?: ResponseInit) { 41 | return redirect(url, { 42 | ...init, 43 | headers: combineHeaders(init?.headers, await createConfettiHeaders()), 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /app/utils/createObservableFileUploadHandler.server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | /** 15 | * This is a fork of the official [unstable_createFileUploadHandler](https://github.com/remix-run/remix/blob/main/packages/remix-node/upload/fileUploadHandler.ts) 16 | * function by me [André König](https://andrekoenig.de). 17 | * 18 | * I have extended the upload handler with two new callback functions 19 | * that can be defined to receive updates on the upload progress and 20 | * the status of a completed upload. 21 | * 22 | */ 23 | 24 | import type { Readable } from "node:stream"; 25 | import type { 26 | UploadHandler, 27 | UploadHandlerPart, 28 | } from "@remix-run/server-runtime"; 29 | 30 | import { randomBytes } from "node:crypto"; 31 | import { createReadStream, createWriteStream, statSync } from "node:fs"; 32 | import { mkdir, rm, stat as statAsync, unlink } from "node:fs/promises"; 33 | import { tmpdir } from "node:os"; 34 | import { basename, dirname, extname, resolve as resolvePath } from "node:path"; 35 | import { finished } from "node:stream"; 36 | import { promisify } from "node:util"; 37 | import { 38 | createReadableStreamFromReadable, 39 | readableStreamToString, 40 | } from "@remix-run/node"; 41 | import { MaxPartSizeExceededError } from "@remix-run/server-runtime"; 42 | // @ts-expect-error 43 | import * as streamSlice from "stream-slice"; 44 | 45 | export type FileUploadHandlerFilterArgs = { 46 | filename: string; 47 | contentType: string; 48 | name: string; 49 | }; 50 | 51 | export type FileUploadHandlerPathResolverArgs = { 52 | filename: string; 53 | contentType: string; 54 | name: string; 55 | }; 56 | 57 | /** 58 | * Chooses the path of the file to be uploaded. If a string is not 59 | * returned the file will not be written. 60 | */ 61 | export type FileUploadHandlerPathResolver = ( 62 | args: FileUploadHandlerPathResolverArgs, 63 | ) => string | undefined; 64 | 65 | export type FileUploadHandlerOptions = { 66 | /** 67 | * Avoid file conflicts by appending a count on the end of the filename 68 | * if it already exists on disk. Defaults to `true`. 69 | */ 70 | avoidFileConflicts?: boolean; 71 | /** 72 | * The directory to write the upload. 73 | */ 74 | directory?: string | FileUploadHandlerPathResolver; 75 | /** 76 | * The name of the file in the directory. Can be a relative path, the directory 77 | * structure will be created if it does not exist. 78 | */ 79 | file?: FileUploadHandlerPathResolver; 80 | /** 81 | * The maximum upload size allowed. If the size is exceeded an error will be thrown. 82 | * Defaults to 3000000B (3MB). 83 | */ 84 | maxPartSize?: number; 85 | /** 86 | * 87 | * @param filename 88 | * @param contentType 89 | * @param name 90 | */ 91 | filter?(args: FileUploadHandlerFilterArgs): boolean | Promise; 92 | 93 | /** 94 | * Callback function invoked upon the successful uploading of a file chunk. 95 | * @param args 96 | * @returns 97 | */ 98 | onProgress?: (args: { 99 | name: string; 100 | filename: string; 101 | contentType: string; 102 | uploadedBytes: number; 103 | }) => void; 104 | 105 | /** 106 | * Callback function triggered upon completion of the entire file upload process. 107 | * @param args 108 | * @returns 109 | */ 110 | onDone?: (args: { 111 | name: string; 112 | filename: string; 113 | contentType: string; 114 | uploadedBytes: number; 115 | }) => void; 116 | }; 117 | 118 | const defaultFilePathResolver: FileUploadHandlerPathResolver = ({ 119 | filename, 120 | }) => { 121 | const ext = filename ? extname(filename) : ""; 122 | return `upload_${randomBytes(4).readUInt32LE(0)}${ext}`; 123 | }; 124 | 125 | async function uniqueFile(filepath: string) { 126 | const ext = extname(filepath); 127 | let uniqueFilepath = filepath; 128 | 129 | for ( 130 | let i = 1; 131 | await statAsync(uniqueFilepath) 132 | .then(() => true) 133 | .catch(() => false); 134 | i++ 135 | ) { 136 | uniqueFilepath = 137 | (ext ? filepath.slice(0, -ext.length) : filepath) + 138 | `-${new Date().getTime()}${ext}`; 139 | } 140 | 141 | return uniqueFilepath; 142 | } 143 | 144 | export function createObservableFileUploadHandler({ 145 | directory = tmpdir(), 146 | avoidFileConflicts = true, 147 | file = defaultFilePathResolver, 148 | filter, 149 | maxPartSize = 3000000, 150 | onProgress, 151 | onDone, 152 | }: FileUploadHandlerOptions = {}): UploadHandler { 153 | return async ({ name, filename, contentType, data }: UploadHandlerPart) => { 154 | if ( 155 | !filename || 156 | (filter && !(await filter({ name, filename, contentType }))) 157 | ) { 158 | return undefined; 159 | } 160 | 161 | const dir = 162 | typeof directory === "string" 163 | ? directory 164 | : directory({ name, filename, contentType }); 165 | 166 | if (!dir) { 167 | return undefined; 168 | } 169 | 170 | const filedir = resolvePath(dir); 171 | const path = 172 | typeof file === "string" ? file : file({ name, filename, contentType }); 173 | 174 | if (!path) { 175 | return undefined; 176 | } 177 | 178 | let filepath = resolvePath(filedir, path); 179 | 180 | if (avoidFileConflicts) { 181 | filepath = await uniqueFile(filepath); 182 | } 183 | 184 | await mkdir(dirname(filepath), { recursive: true }).catch(() => {}); 185 | 186 | const writeFileStream = createWriteStream(filepath); 187 | let size = 0; 188 | let deleteFile = false; 189 | 190 | const onWrite = () => { 191 | if (onProgress) { 192 | onProgress({ name, filename, contentType, uploadedBytes: size }); 193 | } 194 | }; 195 | 196 | try { 197 | for await (const chunk of data) { 198 | size += chunk.byteLength; 199 | if (size > maxPartSize) { 200 | deleteFile = true; 201 | throw new MaxPartSizeExceededError(name, maxPartSize); 202 | } 203 | writeFileStream.write(chunk, onWrite); 204 | } 205 | } finally { 206 | writeFileStream.end(); 207 | await promisify(finished)(writeFileStream); 208 | 209 | if (deleteFile) { 210 | await rm(filepath).catch(() => {}); 211 | } 212 | } 213 | 214 | if (onDone) { 215 | onDone({ name, filename, contentType, uploadedBytes: size }); 216 | } 217 | // TODO: remove this typecast once TS fixed File class regression 218 | // https://github.com/microsoft/TypeScript/issues/52166 219 | return new NodeOnDiskFile(filepath, contentType) as unknown as File; 220 | }; 221 | } 222 | 223 | // TODO: remove this `Omit` usage once TS fixed File class regression 224 | // https://github.com/microsoft/TypeScript/issues/52166 225 | export class NodeOnDiskFile implements Omit { 226 | name: string; 227 | lastModified = 0; 228 | webkitRelativePath = ""; 229 | 230 | // TODO: remove this property once TS fixed File class regression 231 | // https://github.com/microsoft/TypeScript/issues/52166 232 | prototype = File.prototype; 233 | 234 | constructor( 235 | private filepath: string, 236 | public type: string, 237 | private slicer?: { start: number; end: number }, 238 | ) { 239 | this.name = basename(filepath); 240 | } 241 | 242 | get size(): number { 243 | const stats = statSync(this.filepath); 244 | 245 | if (this.slicer) { 246 | const slice = this.slicer.end - this.slicer.start; 247 | return slice < 0 ? 0 : slice > stats.size ? stats.size : slice; 248 | } 249 | 250 | return stats.size; 251 | } 252 | 253 | slice(start?: number, end?: number, type?: string): Blob { 254 | let validatedStart = 0; 255 | let validatedEnd = this.size; 256 | 257 | if (typeof start === "number" && start < 0) { 258 | validatedStart = this.size + start; 259 | } 260 | if (typeof end === "number" && end < 0) { 261 | validatedEnd = this.size + end; 262 | } 263 | 264 | const startOffset = this.slicer?.start || 0; 265 | 266 | const startWithOffset = startOffset + validatedStart; 267 | const endWithOffset = startOffset + validatedEnd; 268 | 269 | return new NodeOnDiskFile( 270 | this.filepath, 271 | typeof type === "string" ? type : this.type, 272 | { 273 | start: startWithOffset, 274 | end: endWithOffset, 275 | }, 276 | // TODO: remove this typecast once TS fixed File class regression 277 | // https://github.com/microsoft/TypeScript/issues/52166 278 | ) as unknown as Blob; 279 | } 280 | 281 | async arrayBuffer(): Promise { 282 | let stream: Readable = createReadStream(this.filepath); 283 | if (this.slicer) { 284 | stream = stream.pipe( 285 | streamSlice.slice(this.slicer.start, this.slicer.end), 286 | ); 287 | } 288 | 289 | return new Promise((resolve, reject) => { 290 | // biome-ignore lint/suspicious/noExplicitAny: 291 | const buf: any[] = []; 292 | stream.on("data", (chunk) => buf.push(chunk)); 293 | stream.on("end", () => resolve(Buffer.concat(buf))); 294 | stream.on("error", (err) => reject(err)); 295 | }); 296 | } 297 | 298 | // biome-ignore lint/suspicious/noExplicitAny: 299 | stream(): ReadableStream; 300 | stream(): NodeJS.ReadableStream; 301 | // biome-ignore lint/suspicious/noExplicitAny: 302 | stream(): ReadableStream | NodeJS.ReadableStream { 303 | let stream: Readable = createReadStream(this.filepath); 304 | if (this.slicer) { 305 | stream = stream.pipe( 306 | streamSlice.slice(this.slicer.start, this.slicer.end), 307 | ); 308 | } 309 | return createReadableStreamFromReadable(stream); 310 | } 311 | 312 | async text(): Promise { 313 | return readableStreamToString(this.stream()); 314 | } 315 | 316 | public get [Symbol.toStringTag]() { 317 | return "File"; 318 | } 319 | 320 | remove(): Promise { 321 | return unlink(this.filepath); 322 | } 323 | getFilePath(): string { 324 | return this.filepath; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /app/utils/misc.server.ts: -------------------------------------------------------------------------------- 1 | // Borrowed from [The Epic Stack](https://github.com/epicweb-dev/epic-stack) by [Kent C. Dodds](https://kentcdodds.com/). 2 | // https://github.com/kentcdodds/epic-stack-example-confetti/blob/768051d3fe07a9e981b5eb04abf928175adb93eb/app/utils/misc.tsx 3 | 4 | export function combineHeaders( 5 | ...headers: Array 6 | ) { 7 | const combined = new Headers(); 8 | for (const header of headers) { 9 | if (!header) continue; 10 | for (const [key, value] of new Headers(header).entries()) { 11 | combined.append(key, value); 12 | } 13 | } 14 | return combined; 15 | } 16 | -------------------------------------------------------------------------------- /app/utils/useUploadProgress.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @akoenig/remix-observable-file-upload-demo 3 | * 4 | * Copyright, 2023 - André König, Hamburg, Germany 5 | * 6 | * All rights reserved 7 | */ 8 | 9 | /** 10 | * @author André König 11 | * 12 | */ 13 | 14 | import { useEventSource } from "remix-utils/sse/react"; 15 | 16 | export const useUploadProgress = ( 17 | uploadId: string, 18 | progressBaseUrl = "/upload/progress", 19 | ) => { 20 | const progressStream = useEventSource(`${progressBaseUrl}/${uploadId}`, { 21 | event: uploadId.toString(), 22 | }); 23 | 24 | if (progressStream) { 25 | try { 26 | const event = JSON.parse(progressStream) as T; 27 | 28 | return { success: true, event } as const; 29 | } catch (cause) { 30 | return { success: false }; 31 | } 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /assets/ad6e4b67-8ff2-4e8a-b520-65a2cf74fa67.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoenig/remix-observable-file-upload-demo/00cbcd85f0f2ac6e3867c9566187b65b1c7a631f/assets/ad6e4b67-8ff2-4e8a-b520-65a2cf74fa67.gif -------------------------------------------------------------------------------- /assets/ad6e4b67-8ff2-4e8a-b520-65a2cf74fa67.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoenig/remix-observable-file-upload-demo/00cbcd85f0f2ac6e3867c9566187b65b1c7a631f/assets/ad6e4b67-8ff2-4e8a-b520-65a2cf74fa67.png -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.3.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "indentStyle": "space" 8 | }, 9 | "linter": { 10 | "enabled": true, 11 | "rules": { 12 | "recommended": true 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoenig/remix-observable-file-upload-demo/00cbcd85f0f2ac6e3867c9566187b65b1c7a631f/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "~/components", 14 | "utils": "~/framework/shadcn" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /devbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "nodejs@20", 4 | "bun@1", 5 | "flyctl@0.1.127" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /devbox.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfile_version": "1", 3 | "packages": { 4 | "bun@1": { 5 | "last_modified": "2023-11-09T21:23:13Z", 6 | "resolved": "github:NixOS/nixpkgs/1f37660f64a850233baab512c9b9bd83fb72be57#bun", 7 | "source": "devbox-search", 8 | "version": "1.0.11", 9 | "systems": { 10 | "aarch64-darwin": { 11 | "store_path": "/nix/store/h5n8rgpkmd64q9ykxgifki7zn8g5iq7d-bun-1.0.11" 12 | }, 13 | "aarch64-linux": { 14 | "store_path": "/nix/store/2x7a9cgdcssfcg72fqz7yxqwacc169n9-bun-1.0.11" 15 | }, 16 | "x86_64-linux": { 17 | "store_path": "/nix/store/zqprz2gxw7vn23hjzkzdkvvyr3yjs1y1-bun-1.0.11" 18 | } 19 | } 20 | }, 21 | "flyctl@0.1.127": { 22 | "last_modified": "2023-11-19T17:46:56Z", 23 | "resolved": "github:NixOS/nixpkgs/0bf3f5cf6a98b5d077cdcdb00a6d4b3d92bc78b5#flyctl", 24 | "source": "devbox-search", 25 | "version": "0.1.127", 26 | "systems": { 27 | "aarch64-darwin": { 28 | "store_path": "/nix/store/2ysmzrmqw9mk006m02p7sg2a3y871cqj-flyctl-0.1.127" 29 | }, 30 | "aarch64-linux": { 31 | "store_path": "/nix/store/n1hpwpwdr2smxs4489rsj9ippq9vm1ra-flyctl-0.1.127" 32 | }, 33 | "x86_64-darwin": { 34 | "store_path": "/nix/store/gpbrjpnj8q4cf7wbdhqd2bx82h18ycj8-flyctl-0.1.127" 35 | }, 36 | "x86_64-linux": { 37 | "store_path": "/nix/store/y27kki46r3bfcsgjjdphizzcfsllpxif-flyctl-0.1.127" 38 | } 39 | } 40 | }, 41 | "nodejs@20": { 42 | "last_modified": "2023-10-25T20:49:13Z", 43 | "resolved": "github:NixOS/nixpkgs/75a52265bda7fd25e06e3a67dee3f0354e73243c#nodejs_20", 44 | "source": "devbox-search", 45 | "version": "20.9.0", 46 | "systems": { 47 | "aarch64-darwin": { 48 | "store_path": "/nix/store/r8ppkdpxlmlv4magszy4xl816yf1s859-nodejs-20.9.0" 49 | }, 50 | "aarch64-linux": { 51 | "store_path": "/nix/store/j0diijav3hh37amjl67wnq5n3f782ipn-nodejs-20.9.0" 52 | }, 53 | "x86_64-darwin": { 54 | "store_path": "/nix/store/zxh8gvmynd5mzx9hf5bhidb4yy0k4g2s-nodejs-20.9.0" 55 | }, 56 | "x86_64-linux": { 57 | "store_path": "/nix/store/17g2kfxglsl3rncfasqrxqs2g3bjin3k-nodejs-20.9.0" 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for demo-remix-observable-file-upload on 2023-11-23T10:20:56+01:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "demo-remix-observable-file-upload" 7 | primary_region = "ams" 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 3000 13 | force_https = true 14 | auto_stop_machines = true 15 | auto_start_machines = true 16 | min_machines_running = 0 17 | processes = ["app"] 18 | 19 | [checks] 20 | [checks.status] 21 | port = 3000 22 | type = "http" 23 | interval = "10s" 24 | timeout = "2s" 25 | grace_period = "5s" 26 | method = "GET" 27 | path = "/healthz" 28 | protocol = "http" 29 | tls_skip_verify = false -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@akoenig/remix-observable-file-upload-demo", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "vite build && vite build --ssr", 8 | "dev": "vite dev", 9 | "lint": "biome check app --apply", 10 | "start": "remix-serve ./build/index.js", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@radix-ui/react-icons": "^1.3.0", 15 | "@radix-ui/react-progress": "^1.0.3", 16 | "@radix-ui/react-separator": "^1.0.3", 17 | "@radix-ui/react-slot": "^1.0.2", 18 | "@remix-run/node": "^2.3.0", 19 | "@remix-run/react": "^2.3.0", 20 | "@remix-run/serve": "^2.3.0", 21 | "class-variance-authority": "^0.7.0", 22 | "clsx": "^2.0.0", 23 | "confetti-react": "^2.5.0", 24 | "isbot": "^3.6.8", 25 | "nanoid": "^5.0.3", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "remix-utils": "^7.1.0", 29 | "tailwind-merge": "^2.0.0", 30 | "tailwindcss-animate": "^1.0.7" 31 | }, 32 | "devDependencies": { 33 | "@biomejs/biome": "1.4.1", 34 | "@flydotio/dockerfile": "latest", 35 | "@remix-run/dev": "^2.3.0", 36 | "@remix-run/eslint-config": "^2.3.0", 37 | "@types/react": "^18.2.20", 38 | "@types/react-dom": "^18.2.7", 39 | "autoprefixer": "^10.4.16", 40 | "eslint": "^8.38.0", 41 | "tailwindcss": "^3.3.5", 42 | "typescript": "^5.1.6", 43 | "vite": "^5.0.0", 44 | "vite-tsconfig-paths": "^4.2.1" 45 | }, 46 | "engines": { 47 | "node": ">=18.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoenig/remix-observable-file-upload-demo/00cbcd85f0f2ac6e3867c9566187b65b1c7a631f/public/.gitkeep -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: "2rem", 14 | screens: { 15 | "2xl": "1400px", 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | muted: { 38 | DEFAULT: "hsl(var(--muted))", 39 | foreground: "hsl(var(--muted-foreground))", 40 | }, 41 | accent: { 42 | DEFAULT: "hsl(var(--accent))", 43 | foreground: "hsl(var(--accent-foreground))", 44 | }, 45 | popover: { 46 | DEFAULT: "hsl(var(--popover))", 47 | foreground: "hsl(var(--popover-foreground))", 48 | }, 49 | card: { 50 | DEFAULT: "hsl(var(--card))", 51 | foreground: "hsl(var(--card-foreground))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: "var(--radius)", 56 | md: "calc(var(--radius) - 2px)", 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | keyframes: { 60 | "accordion-down": { 61 | from: { height: 0 }, 62 | to: { height: "var(--radix-accordion-content-height)" }, 63 | }, 64 | "accordion-up": { 65 | from: { height: "var(--radix-accordion-content-height)" }, 66 | to: { height: 0 }, 67 | }, 68 | }, 69 | animation: { 70 | "accordion-down": "accordion-down 0.2s ease-out", 71 | "accordion-up": "accordion-up 0.2s ease-out", 72 | }, 73 | }, 74 | }, 75 | plugins: [require("tailwindcss-animate")], 76 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "module": "ESNext", 9 | "moduleResolution": "Bundler", 10 | "resolveJsonModule": true, 11 | "target": "ES2022", 12 | "strict": true, 13 | "allowJs": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": ".", 16 | "skipLibCheck": true, 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | 21 | // Remix takes care of building everything in `remix build`. 22 | "noEmit": true, 23 | "allowImportingTsExtensions": true, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { unstable_vitePlugin as remix } from "@remix-run/dev"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [remix(), tsconfigPaths()], 7 | }); 8 | --------------------------------------------------------------------------------