├── .envexample ├── .gitignore ├── README.md ├── app ├── entry.client.jsx ├── entry.server.jsx ├── root.jsx ├── routes │ ├── auth │ │ ├── login.jsx │ │ └── logout.jsx │ ├── index.jsx │ ├── posts.jsx │ └── posts │ │ ├── $postId.jsx │ │ ├── index.jsx │ │ └── new.jsx ├── styles │ └── global.css └── utils │ ├── db.server.ts │ └── session.server.ts ├── jsconfig.json ├── package-lock.json ├── package.json ├── prisma ├── schema.prisma └── seed.js ├── public └── favicon.ico └── remix.config.js /.envexample: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./dev.db" 2 | SESSION_SECRET="secret" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /public/build 4 | /.cache 5 | /prisma/dev.db 6 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Blog 2 | 3 | This is the blog app from my [Remix Crash Course]() on YouTube 4 | 5 | ## Usage 6 | 7 | Rename .envexample to .env and change session secret 8 | 9 | Install dependencies 10 | 11 | ```sh 12 | npm install 13 | ``` 14 | 15 | Load .env variables 16 | 17 | ```sh 18 | npx prisma generate 19 | ``` 20 | 21 | Setup Database 22 | 23 | ```sh 24 | npx prisma db push 25 | ``` 26 | 27 | Run dev server 28 | 29 | ```sh 30 | npm run dev 31 | ``` 32 | -------------------------------------------------------------------------------- /app/entry.client.jsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.jsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import { RemixServer } from "remix"; 3 | 4 | export default function handleRequest( 5 | request, 6 | responseStatusCode, 7 | responseHeaders, 8 | remixContext 9 | ) { 10 | let markup = renderToString( 11 | 12 | ); 13 | 14 | responseHeaders.set("Content-Type", "text/html"); 15 | 16 | return new Response("" + markup, { 17 | status: responseStatusCode, 18 | headers: responseHeaders, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /app/root.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet, LiveReload, Link, Links, Meta, useLoaderData } from 'remix' 2 | import globalStylesUrl from '~/styles/global.css' 3 | import { getUser } from '~/utils/session.server' 4 | 5 | export const links = () => [{ rel: 'stylesheet', href: globalStylesUrl }] 6 | 7 | export const meta = () => { 8 | const description = 'A cool blog built with Remix' 9 | const keywords = 'remix, react, javascript' 10 | 11 | return { 12 | description, 13 | keywords, 14 | } 15 | } 16 | 17 | export const loader = async ({ request }) => { 18 | const user = await getUser(request) 19 | const data = { 20 | user, 21 | } 22 | return data 23 | } 24 | 25 | export default function App() { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | function Document({ children, title }) { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | {title ? title : 'Remix Blog'} 44 | 45 | 46 | {children} 47 | {process.env.NODE_ENV === 'development' ? : null} 48 | 49 | 50 | ) 51 | } 52 | 53 | function Layout({ children }) { 54 | const { user } = useLoaderData() 55 | 56 | return ( 57 | <> 58 | 82 | 83 |
{children}
84 | 85 | ) 86 | } 87 | 88 | export function ErrorBoundary({ error }) { 89 | console.log(error) 90 | return ( 91 | 92 | 93 |

Error

94 |

{error.message}

95 |
96 |
97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /app/routes/auth/login.jsx: -------------------------------------------------------------------------------- 1 | import { useActionData, redirect, json } from 'remix' 2 | import { db } from '~/utils/db.server' 3 | import { createUserSession, login, register } from '~/utils/session.server' 4 | 5 | function validateUsername(username) { 6 | if (typeof username !== 'string' || username.length < 3) { 7 | return 'Username must be at least 3 characters' 8 | } 9 | } 10 | 11 | function validatePassword(password) { 12 | if (typeof password !== 'string' || password.length < 6) { 13 | return 'Password must be at least 6 characters' 14 | } 15 | } 16 | 17 | function badRequest(data) { 18 | return json(data, { status: 400 }) 19 | } 20 | 21 | export const action = async ({ request }) => { 22 | const form = await request.formData() 23 | const loginType = form.get('loginType') 24 | const username = form.get('username') 25 | const password = form.get('password') 26 | 27 | const fields = { loginType, username, password } 28 | 29 | const fieldErrors = { 30 | username: validateUsername(username), 31 | password: validatePassword(password), 32 | } 33 | 34 | if (Object.values(fieldErrors).some(Boolean)) { 35 | return badRequest({ fieldErrors, fields }) 36 | } 37 | 38 | switch (loginType) { 39 | case 'login': { 40 | // Find user 41 | const user = await login({ username, password }) 42 | 43 | // Check user 44 | if (!user) { 45 | return badRequest({ 46 | fields, 47 | fieldErrors: { username: 'Invalid credentials' }, 48 | }) 49 | } 50 | 51 | // Create Session 52 | return createUserSession(user.id, '/posts') 53 | } 54 | case 'register': { 55 | // Check if user exists 56 | const userExists = await db.user.findFirst({ 57 | where: { 58 | username, 59 | }, 60 | }) 61 | if (userExists) { 62 | return badRequest({ 63 | fields, 64 | fieldErrors: { username: `User ${username} already exists` }, 65 | }) 66 | } 67 | 68 | // Create user 69 | const user = await register({ username, password }) 70 | if (!user) { 71 | return badRequest({ 72 | fields, 73 | formError: 'Something went wrong', 74 | }) 75 | } 76 | 77 | // Create session 78 | return createUserSession(user.id, '/posts') 79 | } 80 | default: { 81 | return badRequest({ 82 | fields, 83 | formError: 'Login type is invalid', 84 | }) 85 | } 86 | } 87 | 88 | return redirect('/posts') 89 | } 90 | 91 | function Login() { 92 | const actionData = useActionData() 93 | 94 | return ( 95 |
96 |
97 |

Login

98 |
99 | 100 |
101 |
102 |
103 | Login or Register 104 | 116 | 117 | 126 |
127 |
128 | 129 | 135 |
136 | {actionData?.fieldErrors?.username ? ( 137 | 144 | ) : null} 145 |
146 |
147 | 148 |
149 | 150 | 156 |
157 | {actionData?.fieldErrors?.password ? ( 158 | 165 | ) : null} 166 |
167 |
168 | 169 | 172 |
173 |
174 |
175 | ) 176 | } 177 | 178 | export default Login 179 | -------------------------------------------------------------------------------- /app/routes/auth/logout.jsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'remix' 2 | import { logout } from '~/utils/session.server' 3 | 4 | export const action = async ({ request }) => { 5 | return logout(request) 6 | } 7 | 8 | export const loader = async () => { 9 | return redirect('/') 10 | } 11 | -------------------------------------------------------------------------------- /app/routes/index.jsx: -------------------------------------------------------------------------------- 1 | function Home() { 2 | return ( 3 |
4 |

Welcome to Remix!

5 |

6 | Remix is a full stack web framework by the creators of React Router. 7 | This is a simple blog app from the Traversy Media Remix crash course. 8 |

9 |
10 | ) 11 | } 12 | 13 | export default Home 14 | -------------------------------------------------------------------------------- /app/routes/posts.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'remix' 2 | 3 | function Posts() { 4 | return ( 5 | <> 6 | 7 | 8 | ) 9 | } 10 | 11 | export default Posts 12 | -------------------------------------------------------------------------------- /app/routes/posts/$postId.jsx: -------------------------------------------------------------------------------- 1 | import { Link, redirect, useLoaderData } from 'remix' 2 | import { db } from '~/utils/db.server' 3 | import { getUser } from '~/utils/session.server' 4 | 5 | export const loader = async ({ request, params }) => { 6 | const user = await getUser(request) 7 | 8 | const post = await db.post.findUnique({ 9 | where: { id: params.postId }, 10 | }) 11 | 12 | if (!post) throw new Error('Post not found') 13 | 14 | const data = { post, user } 15 | return data 16 | } 17 | 18 | export const action = async ({ request, params }) => { 19 | const form = await request.formData() 20 | if (form.get('_method') === 'delete') { 21 | const user = await getUser(request) 22 | 23 | const post = await db.post.findUnique({ 24 | where: { id: params.postId }, 25 | }) 26 | 27 | if (!post) throw new Error('Post not found') 28 | 29 | if (user && post.userId === user.id) { 30 | await db.post.delete({ where: { id: params.postId } }) 31 | } 32 | 33 | return redirect('/posts') 34 | } 35 | } 36 | 37 | function Post() { 38 | const { post, user } = useLoaderData() 39 | 40 | return ( 41 |
42 |
43 |

{post.title}

44 | 45 | Back 46 | 47 |
48 | 49 |
{post.body}
50 | 51 |
52 | {user.id === post.userId && ( 53 |
54 | 55 | 56 |
57 | )} 58 |
59 |
60 | ) 61 | } 62 | 63 | export default Post 64 | -------------------------------------------------------------------------------- /app/routes/posts/index.jsx: -------------------------------------------------------------------------------- 1 | import { Link, useLoaderData } from 'remix' 2 | import { db } from '~/utils/db.server' 3 | 4 | export const loader = async () => { 5 | const data = { 6 | posts: await db.post.findMany({ 7 | take: 20, 8 | select: { id: true, title: true, createdAt: true }, 9 | orderBy: { createdAt: 'desc' }, 10 | }), 11 | } 12 | 13 | return data 14 | } 15 | 16 | function PostItems() { 17 | const { posts } = useLoaderData() 18 | 19 | return ( 20 | <> 21 |
22 |

Posts

23 | 24 | New Post 25 | 26 |
27 |
    28 | {posts.map((post) => ( 29 |
  • 30 | 31 |

    {post.title}

    32 | {new Date(post.createdAt).toLocaleString()} 33 | 34 |
  • 35 | ))} 36 |
37 | 38 | ) 39 | } 40 | 41 | export default PostItems 42 | -------------------------------------------------------------------------------- /app/routes/posts/new.jsx: -------------------------------------------------------------------------------- 1 | import { Link, redirect, useActionData, json } from 'remix' 2 | import { db } from '~/utils/db.server' 3 | import { getUser } from '~/utils/session.server' 4 | 5 | function validateTitle(title) { 6 | if (typeof title !== 'string' || title.length < 3) { 7 | return 'Title must be at least 3 characters' 8 | } 9 | } 10 | 11 | function validateBody(body) { 12 | if (typeof body !== 'string' || body.length < 10) { 13 | return 'Body must be at least 10 characters' 14 | } 15 | } 16 | 17 | function badRequest(data) { 18 | return json(data, { status: 400 }) 19 | } 20 | 21 | export const action = async ({ request }) => { 22 | const form = await request.formData() 23 | const title = form.get('title') 24 | const body = form.get('body') 25 | const user = await getUser(request) 26 | 27 | const fields = { title, body } 28 | 29 | const fieldErrors = { 30 | title: validateTitle(title), 31 | body: validateBody(body), 32 | } 33 | 34 | if (Object.values(fieldErrors).some(Boolean)) { 35 | console.log(fieldErrors) 36 | return badRequest({ fieldErrors, fields }) 37 | } 38 | 39 | const post = await db.post.create({ data: { ...fields, userId: user.id } }) 40 | 41 | return redirect(`/posts/${post.id}`) 42 | } 43 | 44 | function NewPost() { 45 | const actionData = useActionData() 46 | return ( 47 | <> 48 |
49 |

New Post

50 | 51 | Back 52 | 53 |
54 | 55 |
56 |
57 |
58 | 59 | 65 |
66 | {actionData?.fieldErrors?.title ? ( 67 | 74 | ) : null} 75 |
76 |
77 |
78 | 79 |