├── public └── favicon.ico ├── remix.env.d.ts ├── app ├── entry.client.tsx ├── video.tsx ├── styles │ ├── dark.css │ ├── index.css │ └── global.css ├── components │ └── remotion │ │ ├── Video.tsx │ │ ├── github.module.css │ │ └── GitHub.tsx ├── entry.server.tsx ├── github-cache.ts ├── routes │ ├── api.video.$name.tsx │ └── index.tsx └── root.tsx ├── .gitignore ├── remix.config.js ├── .github └── workflows │ └── main.yml ├── tsconfig.json ├── fly.toml ├── README.md ├── package.json └── Dockerfile /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgastler/remix-remotion-demo/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/video.tsx: -------------------------------------------------------------------------------- 1 | import { registerRoot } from "remotion"; 2 | import { RemotionVideo } from "./components/remotion/Video"; 3 | 4 | registerRoot(RemotionVideo); 5 | -------------------------------------------------------------------------------- /app/styles/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-foreground: hsl(0, 0%, 100%); 3 | --color-background: hsl(0, 0%, 7%); 4 | --color-links: hsl(213, 100%, 73%); 5 | --color-links-hover: hsl(213, 100%, 80%); 6 | --color-border: hsl(0, 0%, 25%); 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .DS_Store 3 | node_modules/ 4 | 5 | /.cache 6 | /build 7 | /server-build 8 | /public/build 9 | /coverage 10 | tsconfig.tsbuildinfo 11 | 12 | /cypress/videos 13 | /cypress/screenshots 14 | *.local.* 15 | 16 | .env 17 | .env.production 18 | .envrc 19 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | browserBuildDirectory: "public/build", 7 | publicPath: "/build/", 8 | serverBuildDirectory: "build", 9 | devServerPort: 8002 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Fly Deploy 2 | on: [push] 3 | env: 4 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 5 | jobs: 6 | deploy: 7 | name: Deploy app 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: superfly/flyctl-actions@1.1 12 | with: 13 | args: "deploy" 14 | -------------------------------------------------------------------------------- /app/components/remotion/Video.tsx: -------------------------------------------------------------------------------- 1 | import { Composition } from "remotion"; 2 | import { GithubDemo } from "./GitHub"; 3 | 4 | export const RemotionVideo: React.FC = () => { 5 | return ( 6 | <> 7 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "target": "ES2019", 10 | "strict": true, 11 | "paths": { 12 | "~/*": ["./app/*"] 13 | }, 14 | 15 | // Remix takes care of building everything in `remix build`. 16 | "noEmit": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import { RemixServer } from "remix"; 3 | import type { EntryContext } from "remix"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/components/remotion/github.module.css: -------------------------------------------------------------------------------- 1 | @import url("https://rsms.me/inter/inter.css"); 2 | html { 3 | font-family: "Inter", sans-serif; 4 | } 5 | @supports (font-variation-settings: normal) { 6 | html { 7 | font-family: "Inter var", sans-serif; 8 | } 9 | } 10 | 11 | .container { 12 | display: flex; 13 | flex-direction: row; 14 | font-weight: bold; 15 | justify-content: center; 16 | align-items: center; 17 | } 18 | 19 | .stars { 20 | font-size: 0.75em; 21 | margin-top: 2.5px; 22 | } 23 | 24 | @media screen and (max-width: 900px) { 25 | .githubicon { 26 | display: none; 27 | } 28 | .stars { 29 | display: none; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for remix-remotion on 2021-12-05T22:38:35+01:00 2 | 3 | app = "remix-remotion" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [env] 10 | 11 | [experimental] 12 | allowed_public_ports = [] 13 | auto_rollback = true 14 | 15 | [[services]] 16 | http_checks = [] 17 | internal_port = 8080 18 | processes = ["app"] 19 | protocol = "tcp" 20 | script_checks = [] 21 | 22 | [services.concurrency] 23 | hard_limit = 25 24 | soft_limit = 20 25 | type = "connections" 26 | 27 | [[services.ports]] 28 | handlers = ["http"] 29 | port = 80 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = 443 34 | 35 | [[services.tcp_checks]] 36 | grace_period = "1s" 37 | interval = "15s" 38 | restart_limit = 0 39 | timeout = "2s" 40 | -------------------------------------------------------------------------------- /app/styles/index.css: -------------------------------------------------------------------------------- 1 | .video__container { 2 | --gap: 1rem; 3 | --space: 2rem; 4 | display: grid; 5 | grid-auto-rows: min-content; 6 | gap: var(--gap); 7 | padding-top: var(--space); 8 | padding-bottom: var(--space); 9 | } 10 | 11 | @media print, screen and (min-width: 640px) { 12 | .video__container { 13 | --gap: 2rem; 14 | grid-auto-rows: unset; 15 | grid-template-columns: repeat(2, 1fr); 16 | } 17 | } 18 | 19 | @media screen and (min-width: 1024px) { 20 | .video__container { 21 | --gap: 4rem; 22 | } 23 | } 24 | 25 | video { 26 | width: 100%; 27 | height: 100%; 28 | object-fit: cover; 29 | } 30 | 31 | .grid { 32 | display: grid; 33 | grid-template-columns: repeat(2, 1fr); 34 | grid-gap: 1rem; 35 | justify-items: center; 36 | align-content: center; 37 | padding: 0; 38 | } 39 | 40 | .grid ul { 41 | margin: 0; 42 | padding: 0; 43 | } 44 | 45 | .grid li { 46 | list-style: none; 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Fly Setup 6 | 7 | 1. [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/) 8 | 9 | 2. Sign up and log in to Fly 10 | 11 | ```sh 12 | flyctl auth signup 13 | ``` 14 | 15 | 3. Setup Fly. It might ask if you want to deploy, say no since you haven't built the app yet. 16 | 17 | ```sh 18 | flyctl launch 19 | ``` 20 | 21 | ## Development 22 | 23 | From your terminal: 24 | 25 | ```sh 26 | npm run dev 27 | ``` 28 | 29 | This starts your app in development mode, rebuilding assets on file changes. 30 | 31 | ## Deployment 32 | 33 | If you've followed the setup instructions already, all you need to do is run this: 34 | 35 | ```sh 36 | npm run deploy 37 | ``` 38 | 39 | You can run `flyctl info` to get the url and ip address of your server. 40 | 41 | Check out the [fly docs](https://fly.io/docs/getting-started/node/) for more information. 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "remix-app-template", 4 | "description": "", 5 | "license": "", 6 | "scripts": { 7 | "build": "remix build", 8 | "dev": "remix dev", 9 | "postinstall": "remix setup node", 10 | "deploy": "flyctl deploy", 11 | "start": "remix-serve build" 12 | }, 13 | "dependencies": { 14 | "@remix-run/react": "^1.0.6", 15 | "@remix-run/serve": "^1.0.6", 16 | "@remotion/bundler": "^2.5.5", 17 | "@remotion/renderer": "^2.5.5", 18 | "@swc/core": "^1.2.118", 19 | "react": "^17.0.2", 20 | "react-dom": "^17.0.2", 21 | "remix": "^1.0.6", 22 | "remotion": "^2.5.5", 23 | "uglify-js": "^3.14.4" 24 | }, 25 | "devDependencies": { 26 | "@remix-run/dev": "^1.0.6", 27 | "@types/react": "^17.0.24", 28 | "@types/react-dom": "^17.0.9", 29 | "typescript": "^4.1.2" 30 | }, 31 | "engines": { 32 | "node": ">=14" 33 | }, 34 | "sideEffects": false 35 | } 36 | -------------------------------------------------------------------------------- /app/github-cache.ts: -------------------------------------------------------------------------------- 1 | export const cache = [ 2 | { 3 | login: "lgastler", 4 | avatar_url: "https://avatars3.githubusercontent.com/u/15183497?v=4", 5 | followers: 3, 6 | }, 7 | { 8 | login: "ryanflorence", 9 | avatar_url: "https://avatars3.githubusercontent.com/u/100200?v=4", 10 | followers: 7452, 11 | }, 12 | { 13 | login: "mjackson", 14 | avatar_url: "https://avatars3.githubusercontent.com/u/92839?v=4", 15 | followers: 5653, 16 | }, 17 | { 18 | login: "mcansh", 19 | avatar_url: "https://avatars3.githubusercontent.com/u/11698668?v=4", 20 | followers: 87, 21 | }, 22 | { 23 | login: "kentcdodds", 24 | avatar_url: "https://avatars3.githubusercontent.com/u/1500684?v=4", 25 | followers: 22092, 26 | }, 27 | { 28 | login: "jacob-ebey", 29 | avatar_url: "https://avatars3.githubusercontent.com/u/12063586?v=4", 30 | followers: 246, 31 | }, 32 | { 33 | login: "chaance", 34 | avatar_url: "https://avatars3.githubusercontent.com/u/3082153?v=4", 35 | followers: 300, 36 | }, 37 | ]; 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine as base 2 | 3 | # Installs latest Chromium (85) package. 4 | RUN apk add --no-cache \ 5 | chromium \ 6 | nss \ 7 | freetype \ 8 | freetype-dev \ 9 | harfbuzz \ 10 | ca-certificates \ 11 | ttf-freefont \ 12 | ffmpeg \ 13 | font-noto-emoji 14 | 15 | # Tell Puppeteer to skip installing Chrome. We'll be using the installed package. 16 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ 17 | PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser 18 | 19 | 20 | # install all node_modules, including dev 21 | FROM base as deps 22 | 23 | RUN mkdir /app/ 24 | WORKDIR /app/ 25 | 26 | ADD package.json ./ 27 | RUN npm install 28 | 29 | # setup production node_modules 30 | FROM base as production-deps 31 | 32 | RUN mkdir /app/ 33 | WORKDIR /app/ 34 | 35 | COPY --from=deps /app/node_modules /app/node_modules 36 | ADD package.json /app/ 37 | RUN npm prune --production 38 | 39 | # build app 40 | FROM base as build 41 | 42 | RUN mkdir /app/ 43 | WORKDIR /app/ 44 | 45 | COPY --from=deps /app/node_modules /app/node_modules 46 | 47 | # app code changes all the time 48 | ADD . . 49 | RUN npm run postinstall 50 | RUN npm run build 51 | 52 | # build smaller image for running 53 | FROM base 54 | 55 | ENV NODE_ENV=production 56 | 57 | RUN mkdir /app/ 58 | WORKDIR /app/ 59 | 60 | # Add user so we don't need --no-sandbox. 61 | RUN addgroup -S pptruser && adduser -S -g pptruser pptruser \ 62 | && mkdir -p /home/pptruser/Downloads /app \ 63 | && chown -R pptruser:pptruser /home/pptruser \ 64 | && chown -R pptruser:pptruser /app 65 | # Run everything after as non-privileged user. 66 | USER pptruser 67 | 68 | 69 | COPY --from=production-deps /app/node_modules /app/node_modules 70 | COPY --from=build /app /app 71 | ADD . . 72 | 73 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /app/components/remotion/GitHub.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AbsoluteFill, 4 | Img, 5 | interpolate, 6 | measureSpring, 7 | spring, 8 | useCurrentFrame, 9 | useVideoConfig, 10 | } from "remotion"; 11 | import styles from "./github.module.css"; 12 | 13 | export type GithubResponse = { 14 | login: string; 15 | avatar_url: string; 16 | followers: number; 17 | }; 18 | export const GithubDemo: React.FC<{ 19 | data: GithubResponse | null; 20 | }> = ({ data }) => { 21 | const frame = useCurrentFrame(); 22 | const { fps } = useVideoConfig(); 23 | const progress = spring({ 24 | frame, 25 | fps, 26 | config: { 27 | damping: 200, 28 | }, 29 | }); 30 | 31 | const springDur = measureSpring({ fps, config: { damping: 200 } }); 32 | 33 | const titleTranslation = interpolate(progress, [0, 1], [700, 0]); 34 | const subtitleOpacity = interpolate( 35 | frame, 36 | [springDur + 15, springDur + 40], 37 | [0, 1] 38 | ); 39 | 40 | return ( 41 | 48 |
51 | 60 |
61 |
62 |

66 | Hi {data?.login}! 67 |

68 |

72 | You have {data?.followers} followers. 73 |

74 |
75 |
76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /app/routes/api.video.$name.tsx: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import os from "os"; 3 | import path from "path"; 4 | import { bundle } from "@remotion/bundler"; 5 | import { 6 | getCompositions, 7 | renderFrames, 8 | stitchFramesToVideo, 9 | } from "@remotion/renderer"; 10 | 11 | import type { LoaderFunction } from "remix"; 12 | import { cache } from "~/github-cache"; 13 | 14 | const compositionId = "GitHub"; 15 | 16 | export const loader: LoaderFunction = async ({ params, request }) => { 17 | const { name } = params; 18 | 19 | console.log("Incoming request for: ", name); 20 | 21 | try { 22 | let userData = cache.find((user) => user.login === name); 23 | 24 | if (!userData) { 25 | const gitHubResponse = await fetch( 26 | `https://api.github.com/users/${name}` 27 | ); 28 | 29 | if (gitHubResponse.status === 403) { 30 | throw new Error( 31 | "GitHub API rate limit exceeded please try again later" 32 | ); 33 | } 34 | 35 | if (gitHubResponse.status !== 200) { 36 | throw new Error( 37 | `Could not find GitHub user with name ${name}. \nMake sure you have the right name in the url!` 38 | ); 39 | } 40 | 41 | const githubJson = await gitHubResponse.json(); 42 | 43 | userData = { 44 | avatar_url: githubJson.avatar_url, 45 | login: githubJson.login, 46 | followers: githubJson.followers, 47 | }; 48 | } 49 | 50 | const videoProps = { 51 | data: userData, 52 | }; 53 | const bundled = await bundle(path.join(__dirname, "../app/video.tsx")); 54 | const comps = await getCompositions(bundled, { 55 | inputProps: videoProps, 56 | }); 57 | 58 | const video = comps.find((c) => c.id === compositionId); 59 | if (!video) { 60 | throw new Error(`No video called ${compositionId}`); 61 | } 62 | 63 | const tmpDir = await fs.promises.mkdtemp( 64 | path.join(os.tmpdir(), "remotion-") 65 | ); 66 | const { assetsInfo } = await renderFrames({ 67 | config: video, 68 | webpackBundle: bundled, 69 | onStart: () => console.log("Rendering frames..."), 70 | onFrameUpdate: (f) => { 71 | if (f % 10 === 0) { 72 | console.log(`Rendered frame ${f}`); 73 | } 74 | }, 75 | parallelism: null, 76 | outputDir: tmpDir, 77 | inputProps: videoProps, 78 | compositionId, 79 | imageFormat: "jpeg", 80 | }); 81 | 82 | const finalOutput = path.join(tmpDir, "out.mp4"); 83 | await stitchFramesToVideo({ 84 | dir: tmpDir, 85 | force: true, 86 | fps: video.fps, 87 | height: video.height, 88 | width: video.width, 89 | outputLocation: finalOutput, 90 | imageFormat: "jpeg", 91 | assetsInfo, 92 | }); 93 | console.log(finalOutput); 94 | console.log("Video rendered and sent!"); 95 | 96 | const fileStats = fs.statSync(finalOutput); 97 | const readstream = fs.createReadStream( 98 | finalOutput 99 | ) as unknown as ReadableStream; 100 | 101 | const response = new Response(readstream, { 102 | headers: { 103 | "Content-Type": "video/mp4", 104 | "Content-Length": fileStats.size.toString(), 105 | "Cache-Control": "private, max-age=3600", 106 | }, 107 | }); 108 | 109 | return response; 110 | } catch (err: unknown) { 111 | console.error(err); 112 | throw new Response((err as Error).message ?? "Unknown Error", { 113 | status: 500, 114 | }); 115 | } 116 | }; 117 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MetaFunction, 3 | LoaderFunction, 4 | Form, 5 | useActionData, 6 | ActionFunction, 7 | LinksFunction, 8 | useTransition, 9 | } from "remix"; 10 | import { useLoaderData, json, Link } from "remix"; 11 | import { cache } from "~/github-cache"; 12 | import indexStylesUrl from "~/styles/index.css"; 13 | 14 | type IndexData = {}; 15 | type ActionData = { 16 | username?: string; 17 | error?: string; 18 | }; 19 | 20 | export let links: LinksFunction = () => { 21 | return [{ rel: "stylesheet", href: indexStylesUrl }]; 22 | }; 23 | 24 | export let action: ActionFunction = async ({ request }) => { 25 | const formData = await request.formData(); 26 | const username = formData.get("githubUsername"); 27 | 28 | const cacheUser = cache.find((user) => user.login === username); 29 | 30 | if (cacheUser) { 31 | return { 32 | username: cacheUser.login, 33 | }; 34 | } 35 | 36 | const checkUsernameRequest = await fetch( 37 | "https://api.github.com/users/" + username 38 | ); 39 | 40 | const validUsername = (await checkUsernameRequest.status) === 200; 41 | 42 | if (checkUsernameRequest.status === 403) { 43 | return { 44 | error: 45 | "Github API rate limit exceeded. Please try on oft the Remix Team Members (those are cached) or try again later.", 46 | }; 47 | } 48 | 49 | if (!validUsername) { 50 | return { 51 | error: "Not a valid GitHub username", 52 | }; 53 | } 54 | 55 | return { 56 | username, 57 | }; 58 | }; 59 | 60 | // https://remix.run/api/conventions#meta 61 | export let meta: MetaFunction = () => { 62 | return { 63 | title: "Remix & Remotion Demo", 64 | description: "Using remotion.dev on a Remix Resource Route!", 65 | }; 66 | }; 67 | 68 | // https://remix.run/guides/routing#index-routes 69 | export default function Index() { 70 | let data = useLoaderData(); 71 | let actionData = useActionData(); 72 | let transition = useTransition(); 73 | 74 | return ( 75 |
76 |
77 |

Welcome to a simple Remix & Remotion Demo!

78 |

79 | @kentcdodds mentioned 80 | yesterday in the{" "} 81 | Remix Run Discord that 82 | it would be cool to combine{" "} 83 | Remotion with a{" "} 84 | 85 | Remix resource route 86 | {" "} 87 | to render a dynamic video. I thought so too, so I put together this 88 | quick demo. Feel free to check it out.
89 |
90 | Also check out the{" "} 91 | Remotion Docs because I 92 | stitched this together from a bunch of their example code. 93 |

94 |

95 | You can try the demo at the bottom with your GitHub username. Or go 96 | directly to /api/video/:yourGitHubUsername to hit the 97 | resource route directly. 98 |

99 |
100 |
101 |

Render a custom video with your GitHub Profile

102 |
103 | 108 | 109 | {actionData?.error ?

{actionData.error}

: null} 110 |
111 | { 112 |

113 | {transition.state === "submitting" 114 | ? "Checking username..." 115 | : transition.state === "loading" 116 | ? "Generating your video ..." 117 | : null} 118 |

119 | } 120 |
121 |
122 |

Or select one of the Remix Team members

123 |
    124 | {cache 125 | .filter((user) => user.login !== "lgastler") 126 | .map((user) => ( 127 |
  • 128 |
    129 | 134 | 135 |
    136 |
  • 137 | ))} 138 |
139 |
140 |
141 |
142 | {actionData?.username ? ( 143 |
153 |
154 |
155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /app/styles/global.css: -------------------------------------------------------------------------------- 1 | /* 2 | * You can just delete everything here or keep whatever you like, it's just a 3 | * quick baseline! 4 | */ 5 | :root { 6 | --color-foreground: hsl(0, 0%, 7%); 7 | --color-background: hsl(0, 0%, 100%); 8 | --color-links: hsl(213, 100%, 52%); 9 | --color-links-hover: hsl(213, 100%, 43%); 10 | --color-border: hsl(0, 0%, 82%); 11 | --font-body: -apple-system, "Segoe UI", Helvetica Neue, Helvetica, Roboto, 12 | Arial, sans-serif, system-ui, "Apple Color Emoji", "Segoe UI Emoji"; 13 | } 14 | 15 | html { 16 | box-sizing: border-box; 17 | } 18 | 19 | *, 20 | *::before, 21 | *::after { 22 | box-sizing: inherit; 23 | } 24 | 25 | :-moz-focusring { 26 | outline: auto; 27 | } 28 | 29 | :focus { 30 | outline: var(--color-links) solid 2px; 31 | outline-offset: 2px; 32 | } 33 | 34 | html, 35 | body { 36 | padding: 0; 37 | margin: 0; 38 | background-color: var(--color-background); 39 | color: var(--color-foreground); 40 | } 41 | 42 | body { 43 | font-family: var(--font-body); 44 | line-height: 1.5; 45 | } 46 | 47 | a { 48 | color: var(--color-links); 49 | text-decoration: none; 50 | } 51 | 52 | a:hover { 53 | color: var(--color-links-hover); 54 | text-decoration: underline; 55 | } 56 | 57 | hr { 58 | display: block; 59 | height: 1px; 60 | border: 0; 61 | background-color: var(--color-border); 62 | margin-top: 2rem; 63 | margin-bottom: 2rem; 64 | } 65 | 66 | input:where([type="text"]), 67 | input:where([type="search"]) { 68 | display: block; 69 | border: 1px solid var(--color-border); 70 | width: 100%; 71 | font: inherit; 72 | line-height: 1; 73 | height: calc(1ch + 1.5em); 74 | padding-right: 0.5em; 75 | padding-left: 0.5em; 76 | background-color: hsl(0 0% 100% / 20%); 77 | color: var(--color-foreground); 78 | } 79 | 80 | .sr-only { 81 | position: absolute; 82 | width: 1px; 83 | height: 1px; 84 | padding: 0; 85 | margin: -1px; 86 | overflow: hidden; 87 | clip: rect(0, 0, 0, 0); 88 | white-space: nowrap; 89 | border-width: 0; 90 | } 91 | 92 | .container { 93 | --gutter: 16px; 94 | width: 1024px; 95 | max-width: calc(100% - var(--gutter) * 2); 96 | margin-right: auto; 97 | margin-left: auto; 98 | } 99 | 100 | .remix-app { 101 | display: flex; 102 | flex-direction: column; 103 | min-height: 100vh; 104 | min-height: calc(100vh - env(safe-area-inset-bottom)); 105 | } 106 | 107 | .remix-app > * { 108 | width: 100%; 109 | } 110 | 111 | .remix-app__header { 112 | padding-top: 1rem; 113 | padding-bottom: 1rem; 114 | border-bottom: 1px solid var(--color-border); 115 | } 116 | 117 | .remix-app__header-content { 118 | display: flex; 119 | justify-content: space-between; 120 | align-items: center; 121 | flex-direction: column; 122 | gap: 1rem; 123 | } 124 | 125 | @media print, screen and (min-width: 640px) { 126 | .remix-app__header-content { 127 | flex-direction: row; 128 | } 129 | } 130 | 131 | .remix-app__header-home-link { 132 | width: 200px; 133 | height: 30px; 134 | color: var(--color-foreground); 135 | display: flex; 136 | align-items: center; 137 | justify-content: center; 138 | gap: 10px; 139 | } 140 | 141 | .remix-app__header-nav ul { 142 | list-style: none; 143 | margin: 0; 144 | display: flex; 145 | align-items: space-around; 146 | padding-left: 0; 147 | } 148 | 149 | @media print, screen and (min-width: 640px) { 150 | .remix-app__header-nav ul { 151 | gap: 1.5em; 152 | align-items: center; 153 | width: 100%; 154 | } 155 | } 156 | 157 | .remix-app__header-nav li { 158 | font-weight: bold; 159 | padding-left: 0.5rem; 160 | padding-right: 0.5rem; 161 | } 162 | 163 | @media print, screen and (min-width: 640px) { 164 | .remix-app__header-nav li { 165 | padding-left: 0; 166 | padding-right: 0; 167 | } 168 | } 169 | 170 | .remix-app__main { 171 | flex: 1 1 100%; 172 | } 173 | 174 | .remix-app__footer { 175 | padding-top: 1rem; 176 | padding-bottom: 1rem; 177 | border-top: 1px solid var(--color-border); 178 | } 179 | 180 | .remix-app__footer-content { 181 | display: flex; 182 | flex-direction: column; 183 | justify-content: space-around; 184 | gap: 1rem; 185 | align-items: center; 186 | } 187 | 188 | @media print, screen and (min-width: 640px) { 189 | .remix-app__footer-content { 190 | flex-direction: row; 191 | gap: 0rem; 192 | } 193 | } 194 | 195 | .remix__page { 196 | --gap: 1rem; 197 | --space: 2rem; 198 | --min-content: 2; 199 | grid-auto-rows: min-content; 200 | gap: var(--gap); 201 | padding-top: var(--space); 202 | padding-bottom: var(--space); 203 | } 204 | 205 | @media print, screen and (min-width: 640px) { 206 | .remix__page { 207 | --gap: 2rem; 208 | grid-auto-rows: unset; 209 | grid-template-columns: repeat(2, 1fr); 210 | } 211 | } 212 | 213 | @media screen and (min-width: 1024px) { 214 | .remix__page { 215 | --gap: 4rem; 216 | } 217 | } 218 | 219 | .remix__page > main > :first-child { 220 | margin-top: 0; 221 | } 222 | 223 | .remix__page > main > :last-child { 224 | margin-bottom: 0; 225 | } 226 | 227 | .remix__page > aside { 228 | margin: 0; 229 | padding: 1.5ch 2ch; 230 | border: solid 1px var(--color-border); 231 | border-radius: 0.5rem; 232 | } 233 | 234 | .remix__page > aside > :first-child { 235 | margin-top: 0; 236 | } 237 | 238 | .remix__page > aside > :last-child { 239 | margin-bottom: 0; 240 | } 241 | 242 | .remix__form { 243 | display: flex; 244 | flex-direction: column; 245 | gap: 1rem; 246 | padding: 1rem; 247 | border: 1px solid var(--color-border); 248 | border-radius: 0.5rem; 249 | } 250 | 251 | .remix__form > * { 252 | margin-top: 0; 253 | margin-bottom: 0; 254 | } 255 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Link, 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | useCatch, 10 | } from "remix"; 11 | import type { LinksFunction } from "remix"; 12 | 13 | import globalStylesUrl from "~/styles/global.css"; 14 | import darkStylesUrl from "~/styles/dark.css"; 15 | 16 | // https://remix.run/api/app#links 17 | export let links: LinksFunction = () => { 18 | return [ 19 | { rel: "stylesheet", href: globalStylesUrl }, 20 | { 21 | rel: "stylesheet", 22 | href: darkStylesUrl, 23 | media: "(prefers-color-scheme: dark)", 24 | }, 25 | ]; 26 | }; 27 | 28 | // https://remix.run/api/conventions#default-export 29 | // https://remix.run/api/conventions#route-filenames 30 | export default function App() { 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | // https://remix.run/docs/en/v1/api/conventions#errorboundary 41 | export function ErrorBoundary({ error }: { error: Error }) { 42 | console.error(error); 43 | return ( 44 | 45 | 46 |
47 |

There was an error

48 |

{error.message}

49 |
50 |

51 | Hey, developer, you should replace this with what you want your 52 | users to see. 53 |

54 |
55 |
56 |
57 | ); 58 | } 59 | 60 | // https://remix.run/docs/en/v1/api/conventions#catchboundary 61 | export function CatchBoundary() { 62 | let caught = useCatch(); 63 | 64 | let message; 65 | switch (caught.status) { 66 | case 401: 67 | message = ( 68 |

69 | Oops! Looks like you tried to visit a page that you do not have access 70 | to. 71 |

72 | ); 73 | break; 74 | case 404: 75 | message = ( 76 |

Oops! Looks like you tried to visit a page that does not exist.

77 | ); 78 | break; 79 | 80 | default: 81 | throw new Error(caught.data || caught.statusText); 82 | } 83 | 84 | return ( 85 | 86 | 87 |

88 | {caught.status}: {caught.statusText} 89 |

90 | {message} 91 |
92 |
93 | ); 94 | } 95 | 96 | function Document({ 97 | children, 98 | title, 99 | }: { 100 | children: React.ReactNode; 101 | title?: string; 102 | }) { 103 | return ( 104 | 105 | 106 | 107 | 108 | {title ? {title} : null} 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | 116 | {process.env.NODE_ENV === "development" && } 117 | 118 | 119 | ); 120 | } 121 | 122 | function Layout({ children }: { children: React.ReactNode }) { 123 | return ( 124 |
125 |
126 |
127 | 128 | +{" "} 129 | {" "} 134 |

Remotion

135 | 136 | 167 |
168 |
169 |
170 |
{children}
171 |
172 | 206 |
207 | ); 208 | } 209 | 210 | function RemixLogo() { 211 | return ( 212 | 223 | Remix Logo 224 | 225 | 226 | 227 | 228 | 229 | 230 | ); 231 | } 232 | --------------------------------------------------------------------------------