├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── app ├── components │ └── joke.tsx ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx ├── routes │ ├── index.tsx │ ├── jokes.tsx │ ├── jokes │ │ ├── $jokeId.tsx │ │ ├── index.tsx │ │ └── new.tsx │ ├── jokes[.]rss.tsx │ ├── login.tsx │ └── logout.tsx ├── styles │ ├── global-large.css │ ├── global-medium.css │ ├── global.css │ ├── index.css │ ├── jokes.css │ └── login.css └── utils │ ├── db.server.ts │ └── session.server.ts ├── fly.toml ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20211123215721_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public ├── favicon.ico └── fonts │ └── baloo │ ├── License.txt │ └── baloo.woff ├── remix.config.js ├── remix.env.d.ts ├── start_with_migrations.sh └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | 7 | /prisma/dev.db 8 | /prisma/dev.db-journal 9 | .env 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:16-bullseye-slim as base 3 | 4 | # Install openssl for Prisma 5 | RUN apt-get update && apt-get install -y openssl 6 | 7 | # Install all node_modules, including dev dependencies 8 | FROM base as deps 9 | 10 | RUN mkdir /app 11 | WORKDIR /app 12 | 13 | ADD package.json package-lock.json ./ 14 | RUN npm install --production=false 15 | 16 | # Setup production node_modules 17 | FROM base as production-deps 18 | 19 | ENV NODE_ENV production 20 | 21 | RUN mkdir /app 22 | WORKDIR /app 23 | 24 | COPY --from=deps /app/node_modules /app/node_modules 25 | ADD package.json package-lock.json ./ 26 | RUN npm prune --production 27 | 28 | # Build the app 29 | FROM base as build 30 | 31 | RUN mkdir /app 32 | WORKDIR /app 33 | 34 | COPY --from=deps /app/node_modules /app/node_modules 35 | 36 | ADD prisma . 37 | RUN npx prisma generate 38 | 39 | ADD . . 40 | RUN npm run build 41 | 42 | # Finally, build the production image with minimal footprint 43 | FROM base 44 | 45 | ENV NODE_ENV production 46 | 47 | RUN mkdir /app 48 | WORKDIR /app 49 | 50 | COPY --from=production-deps /app/node_modules /app/node_modules 51 | COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma 52 | COPY --from=build /app/build /app/build 53 | COPY --from=build /app/public /app/public 54 | ADD . . 55 | 56 | CMD ["npm", "run", "start"] 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | 40 | ### Using a Template 41 | 42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. 43 | 44 | ```sh 45 | cd .. 46 | # create a new project, and pick a pre-configured host 47 | npx create-remix@latest 48 | cd my-new-remix-app 49 | # remove the new project's app (not the old one!) 50 | rm -rf app 51 | # copy your app over 52 | cp -R ../my-old-remix-app/app app 53 | ``` 54 | -------------------------------------------------------------------------------- /app/components/joke.tsx: -------------------------------------------------------------------------------- 1 | import { Joke } from "@prisma/client"; 2 | import * as React from "react"; 3 | import { Link } from "remix"; 4 | 5 | export function JokeDisplay({ 6 | joke, 7 | isOwner, 8 | canDelete = true, 9 | }: { 10 | joke: Pick; 11 | isOwner: boolean; 12 | canDelete: boolean; 13 | }) { 14 | return ( 15 |
16 |

Here's your hilarious joke:

17 |

{joke.content}

18 | {joke.name} Permalink 19 | {isOwner ? ( 20 |
21 | 22 | 25 |
26 | ) : null} 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /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/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from "remix"; 2 | import { Links, LiveReload, Outlet, useCatch, Meta, Scripts } from "remix"; 3 | 4 | import globalStylesUrl from "./styles/global.css"; 5 | import globalMediumStylesUrl from "./styles/global-medium.css"; 6 | import globalLargeStylesUrl from "./styles/global-large.css"; 7 | 8 | export let links: LinksFunction = () => { 9 | return [ 10 | { 11 | rel: "stylesheet", 12 | href: globalStylesUrl, 13 | }, 14 | { 15 | rel: "stylesheet", 16 | href: globalMediumStylesUrl, 17 | media: "print, (min-width: 640px)", 18 | }, 19 | { 20 | rel: "stylesheet", 21 | href: globalLargeStylesUrl, 22 | media: "screen and (min-width: 1024px)", 23 | }, 24 | ]; 25 | }; 26 | 27 | export let meta: MetaFunction = () => { 28 | let description = `Learn Remix and laugh at the same time!`; 29 | return { 30 | description, 31 | keywords: "Remix,jokes", 32 | "twitter:image": "https://remix-jokes.lol/social.png", 33 | "twitter:card": "summary_large_image", 34 | "twitter:creator": "@remix_run", 35 | "twitter:site": "@remix_run", 36 | "twitter:title": "Remix Jokes", 37 | "twitter:description": description, 38 | }; 39 | }; 40 | 41 | function Document({ 42 | children, 43 | title = `Remix: So great, it's funny!`, 44 | }: { 45 | children: React.ReactNode; 46 | title?: string; 47 | }) { 48 | return ( 49 | 50 | 51 | 52 | 53 | {title} 54 | 55 | 56 | 57 | {children} 58 | {process.env.NODE_ENV === "development" ? : null} 59 | 60 | 61 | 62 | ); 63 | } 64 | 65 | export default function App() { 66 | return ( 67 | 68 | 69 | 70 | ); 71 | } 72 | 73 | export function CatchBoundary() { 74 | let caught = useCatch(); 75 | 76 | return ( 77 | 78 |
79 |

80 | {caught.status} {caught.statusText} 81 |

82 |
83 |
84 | ); 85 | } 86 | 87 | export function ErrorBoundary({ error }: { error: Error }) { 88 | console.error(error); 89 | return ( 90 | 91 |
92 |

App Error

93 |
{error.message}
94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from "remix"; 2 | import { Link } from "remix"; 3 | import stylesUrl from "../styles/index.css"; 4 | 5 | export let links: LinksFunction = () => { 6 | return [ 7 | { 8 | rel: "stylesheet", 9 | href: stylesUrl, 10 | }, 11 | ]; 12 | }; 13 | 14 | export let meta: MetaFunction = () => { 15 | return { 16 | title: "Remix: So great, it's funny!", 17 | description: "Remix jokes app. Learn Remix and laugh at the same time!", 18 | }; 19 | }; 20 | 21 | export default function Index() { 22 | return ( 23 |
24 |
25 |

26 | Remix Jokes! 27 |

28 | 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/routes/jokes.tsx: -------------------------------------------------------------------------------- 1 | import { Joke, User } from "@prisma/client"; 2 | import { LinksFunction, LoaderFunction, useLoaderData } from "remix"; 3 | import { Outlet, Link } from "remix"; 4 | import { db } from "~/utils/db.server"; 5 | import { getUser } from "~/utils/session.server"; 6 | import stylesUrl from "../styles/jokes.css"; 7 | 8 | export let links: LinksFunction = () => { 9 | return [ 10 | { 11 | rel: "stylesheet", 12 | href: stylesUrl, 13 | }, 14 | ]; 15 | }; 16 | 17 | type LoaderData = { 18 | jokeListItems: Array>; 19 | user: User | null; 20 | }; 21 | export let loader: LoaderFunction = async ({ request }) => { 22 | let user = await getUser(request); 23 | let jokeListItems = await db.joke.findMany({ 24 | take: 5, 25 | select: { id: true, name: true }, 26 | orderBy: { createdAt: "desc" }, 27 | }); 28 | let data: LoaderData = { jokeListItems, user }; 29 | return data; 30 | }; 31 | 32 | export default function JokesRoute() { 33 | let data = useLoaderData(); 34 | 35 | return ( 36 |
37 |
38 |
39 |

40 | 41 | 🤪 42 | J🤪KES 43 | 44 |

45 | {data.user ? ( 46 |
47 | {`Hi ${data.user.username}`} 48 |
49 | 52 |
53 |
54 | ) : ( 55 | Login 56 | )} 57 |
58 |
59 |
60 |
61 |
62 | Get a random joke 63 |

Here are a few more jokes to check out:

64 |
    65 | {data.jokeListItems.map((j) => ( 66 |
  • 67 | 68 | {j.name} 69 | 70 |
  • 71 | ))} 72 |
73 | 74 | Add your own 75 | 76 |
77 |
78 | 79 |
80 |
81 |
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /app/routes/jokes/$jokeId.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction, ActionFunction, MetaFunction } from "remix"; 2 | import { Link, useLoaderData, useCatch, redirect, useParams } from "remix"; 3 | import type { Joke } from "@prisma/client"; 4 | import { db } from "~/utils/db.server"; 5 | import { getUserId, requireUserId } from "~/utils/session.server"; 6 | import { JokeDisplay } from "~/components/joke"; 7 | 8 | export let meta: MetaFunction = ({ 9 | data, 10 | }: { 11 | data: LoaderData | undefined; 12 | }) => { 13 | if (!data) { 14 | return { 15 | title: "No joke", 16 | description: "No joke found", 17 | }; 18 | } 19 | return { 20 | title: `"${data.joke.name}" joke`, 21 | description: `Enjoy the "${data.joke.name}" joke and much more`, 22 | }; 23 | }; 24 | 25 | type LoaderData = { joke: Joke; isOwner: boolean }; 26 | 27 | export let loader: LoaderFunction = async ({ params, request }) => { 28 | let userId = await getUserId(request); 29 | let joke = await db.joke.findUnique({ 30 | where: { id: params.jokeId }, 31 | }); 32 | if (!joke) { 33 | throw new Response("What a joke! Not found.", { 34 | status: 404, 35 | }); 36 | } 37 | let data: LoaderData = { joke, isOwner: joke.jokesterId === userId }; 38 | return data; 39 | }; 40 | 41 | export let action: ActionFunction = async ({ request, params }) => { 42 | let form = await request.formData(); 43 | if (form.get("_method") === "delete") { 44 | let userId = await requireUserId(request); 45 | let joke = await db.joke.findUnique({ 46 | where: { id: params.jokeId }, 47 | }); 48 | if (!joke) { 49 | throw new Response("Can't delete what does not exist", { status: 404 }); 50 | } 51 | if (joke.jokesterId !== userId) { 52 | throw new Response("Pssh, nice try. That's not your joke", { 53 | status: 401, 54 | }); 55 | } 56 | await db.joke.delete({ where: { id: params.jokeId } }); 57 | return redirect("/jokes"); 58 | } 59 | }; 60 | 61 | export default function JokeRoute() { 62 | let data = useLoaderData(); 63 | 64 | return ; 65 | } 66 | 67 | export function CatchBoundary() { 68 | let caught = useCatch(); 69 | let params = useParams(); 70 | switch (caught.status) { 71 | case 404: { 72 | return ( 73 |
74 | Huh? What the heck is {params.jokeId}? 75 |
76 | ); 77 | } 78 | case 401: { 79 | return ( 80 |
81 | Sorry, but {params.jokeId} is not your joke. 82 |
83 | ); 84 | } 85 | default: { 86 | throw new Error(`Unhandled error: ${caught.status}`); 87 | } 88 | } 89 | } 90 | 91 | export function ErrorBoundary({ error }: { error: Error }) { 92 | console.error(error); 93 | let { jokeId } = useParams(); 94 | return ( 95 |
{`There was an error loading joke by the id ${jokeId}. Sorry.`}
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /app/routes/jokes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "remix"; 2 | import { useLoaderData, Link, useCatch } from "remix"; 3 | import type { Joke } from "@prisma/client"; 4 | import { db } from "~/utils/db.server"; 5 | 6 | type LoaderData = { randomJoke: Joke }; 7 | 8 | export let loader: LoaderFunction = async () => { 9 | let count = await db.joke.count(); 10 | let randomRowNumber = Math.floor(Math.random() * count); 11 | let [randomJoke] = await db.joke.findMany({ 12 | take: 1, 13 | skip: randomRowNumber, 14 | }); 15 | if (!randomJoke) { 16 | throw new Response("No random joke found", { 17 | status: 404, 18 | }); 19 | } 20 | let data: LoaderData = { randomJoke }; 21 | return data; 22 | }; 23 | 24 | export default function JokesIndexRoute() { 25 | let data = useLoaderData(); 26 | 27 | return ( 28 |
29 |

Here's a random joke:

30 |

{data.randomJoke.content}

31 | "{data.randomJoke.name}" Permalink 32 |
33 | ); 34 | } 35 | 36 | export function CatchBoundary() { 37 | let caught = useCatch(); 38 | 39 | if (caught.status === 404) { 40 | return ( 41 |
There are no jokes to display.
42 | ); 43 | } 44 | throw new Error(`Unexpected caught response with status: ${caught.status}`); 45 | } 46 | 47 | export function ErrorBoundary({ error }: { error: Error }) { 48 | console.error(error); 49 | return
I did a whoopsies.
; 50 | } 51 | -------------------------------------------------------------------------------- /app/routes/jokes/new.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "remix"; 2 | import { 3 | useActionData, 4 | redirect, 5 | useCatch, 6 | Link, 7 | Form, 8 | useTransition, 9 | } from "remix"; 10 | import { JokeDisplay } from "~/components/joke"; 11 | import { db } from "~/utils/db.server"; 12 | import { requireUserId, getUserId } from "~/utils/session.server"; 13 | 14 | export let loader: LoaderFunction = async ({ request }) => { 15 | let userId = await getUserId(request); 16 | if (!userId) { 17 | throw new Response("Unauthorized", { status: 401 }); 18 | } 19 | return {}; 20 | }; 21 | 22 | function validateJokeContent(content: string) { 23 | if (content.length < 10) { 24 | return `That joke is too short`; 25 | } 26 | } 27 | 28 | function validateJokeName(name: string) { 29 | if (name.length < 2) { 30 | return `That joke's name is too short`; 31 | } 32 | } 33 | 34 | type ActionData = { 35 | formError?: string; 36 | fieldErrors?: { 37 | name: string | undefined; 38 | content: string | undefined; 39 | }; 40 | fields?: { 41 | name: string; 42 | content: string; 43 | }; 44 | }; 45 | 46 | export let action: ActionFunction = async ({ 47 | request, 48 | }): Promise => { 49 | let userId = await requireUserId(request); 50 | let form = await request.formData(); 51 | let name = form.get("name"); 52 | let content = form.get("content"); 53 | if (typeof name !== "string" || typeof content !== "string") { 54 | return { formError: `Form not submitted correctly.` }; 55 | } 56 | 57 | let fieldErrors = { 58 | name: validateJokeName(name), 59 | content: validateJokeContent(content), 60 | }; 61 | let fields = { name, content }; 62 | if (Object.values(fieldErrors).some(Boolean)) { 63 | return { fieldErrors, fields }; 64 | } 65 | 66 | let joke = await db.joke.create({ 67 | data: { ...fields, jokesterId: userId }, 68 | }); 69 | return redirect(`/jokes/${joke.id}`); 70 | }; 71 | 72 | export default function NewJokeRoute() { 73 | let actionData = useActionData(); 74 | let transition = useTransition(); 75 | 76 | if (transition.submission) { 77 | let name = transition.submission.formData.get("name"); 78 | let content = transition.submission.formData.get("content"); 79 | if ( 80 | typeof name === "string" && 81 | typeof content === "string" && 82 | !validateJokeContent(content) && 83 | !validateJokeName(name) 84 | ) { 85 | return ( 86 | 91 | ); 92 | } 93 | } 94 | 95 | return ( 96 |
97 |

Add your own hilarious joke

98 |
99 |
100 | 112 | {actionData?.fieldErrors?.name ? ( 113 | 116 | ) : null} 117 |
118 |
119 |