├── .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 |
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 |
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 |
101 | >
102 | )
103 | }
104 |
105 | export default NewPost
106 |
--------------------------------------------------------------------------------
/app/styles/global.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400&display=swap');
2 |
3 | * {
4 | box-sizing: border-box;
5 | margin: 0;
6 | padding: 0;
7 | }
8 |
9 | body {
10 | font-family: 'Poppins', sans-serif;
11 | }
12 |
13 | a {
14 | text-decoration: none;
15 | color: #000;
16 | }
17 |
18 | ul {
19 | list-style: none;
20 | }
21 |
22 | p {
23 | margin: 10px 0;
24 | }
25 |
26 | .container {
27 | width: 100%;
28 | max-width: 960px;
29 | margin: auto;
30 | padding: 0 30px;
31 | }
32 |
33 | .logo {
34 | font-size: x-large;
35 | text-transform: uppercase;
36 | }
37 |
38 | .navbar {
39 | display: flex;
40 | justify-content: space-between;
41 | align-items: center;
42 | margin-bottom: 30px;
43 | background-color: #f4f4f4;
44 | padding: 10px 30px;
45 | text-transform: uppercase;
46 | }
47 |
48 | .navbar ul {
49 | display: flex;
50 | justify-content: space-between;
51 | align-items: center;
52 | }
53 |
54 | .navbar li {
55 | margin-left: 20px;
56 | }
57 |
58 | .navbar ul li a {
59 | color: #000;
60 | text-transform: uppercase;
61 | font-size: 14px;
62 | font-weight: bold;
63 | }
64 |
65 | .navbar ul li a:hover {
66 | color: #333;
67 | border-bottom: 1px solid #333;
68 | }
69 |
70 | .btn {
71 | display: inline-block;
72 | background: #000;
73 | color: #fff;
74 | border: none;
75 | padding: 10px 20px;
76 | margin: 5px;
77 | border-radius: 5px;
78 | cursor: pointer;
79 | text-decoration: none;
80 | font-size: 15px;
81 | font-family: inherit;
82 | }
83 |
84 | .btn-reverse {
85 | background: #fff;
86 | color: #000;
87 | border-color: #000;
88 | }
89 |
90 | .btn:focus {
91 | outline: none;
92 | }
93 |
94 | .btn:active {
95 | transform: scale(0.98);
96 | }
97 |
98 | .btn-block {
99 | display: block;
100 | width: 100%;
101 | }
102 |
103 | .btn-delete {
104 | background: darkred;
105 | color: #fff;
106 | border-color: darkred;
107 | font-size: 13px;
108 | padding: 5px 10px;
109 | }
110 |
111 | .page-header {
112 | display: flex;
113 | justify-content: space-between;
114 | align-items: center;
115 | margin-bottom: 30px;
116 | }
117 |
118 | .page-footer {
119 | display: flex;
120 | justify-content: space-between;
121 | align-items: center;
122 | margin-top: 30px;
123 | }
124 |
125 | .form-control {
126 | margin: 20px 0;
127 | }
128 |
129 | .form-control label {
130 | display: block;
131 | }
132 |
133 | .form-control input,
134 | .form-control textarea {
135 | width: 100%;
136 | height: 40px;
137 | margin: 5px;
138 | padding: 3px 7px;
139 | font-size: 17px;
140 | }
141 |
142 | .form-control textarea {
143 | height: 200px;
144 | }
145 |
146 | .form-control-check {
147 | display: flex;
148 | align-items: center;
149 | justify-content: space-between;
150 | }
151 |
152 | .form-control-check label {
153 | flex: 1;
154 | }
155 |
156 | .form-control-check input {
157 | flex: 2;
158 | height: 20px;
159 | }
160 |
161 | .posts-list {
162 | display: grid;
163 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
164 | gap: 20px;
165 | justify-content: space-between;
166 | align-items: flex-start;
167 | }
168 |
169 | .posts-list li {
170 | padding: 20px;
171 | border: 1px #333 solid;
172 | border-radius: 5px;
173 | }
174 |
175 | .posts-list li h3 {
176 | margin-bottom: 5px;
177 | }
178 |
179 | .error {
180 | color: darkred;
181 | }
182 |
183 | .auth-container {
184 | max-width: 600px;
185 | margin: auto;
186 | }
187 |
188 | .auth-container h1 {
189 | text-align: center;
190 | }
191 |
192 | .auth-container .pageHeader {
193 | display: block;
194 | }
195 |
196 | .auth-container fieldset {
197 | padding: 15px;
198 | border-radius: 5px;
199 | }
200 |
201 | .auth-container fieldset label {
202 | margin-right: 10px;
203 | }
204 |
--------------------------------------------------------------------------------
/app/utils/db.server.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client'
2 |
3 | let db: PrismaClient
4 |
5 | declare global {
6 | var __db: PrismaClient | undefined
7 | }
8 |
9 | if (process.env.NODE_ENV === 'production') {
10 | db = new PrismaClient()
11 | db.$connect()
12 | } else {
13 | if (!global.__db) {
14 | global.__db = new PrismaClient()
15 | global.__db.$connect()
16 | }
17 | db = global.__db
18 | }
19 |
20 | export { db }
21 |
--------------------------------------------------------------------------------
/app/utils/session.server.ts:
--------------------------------------------------------------------------------
1 | import bcrypt from 'bcrypt'
2 | import { db } from './db.server'
3 | import { createCookieSessionStorage, redirect } from 'remix'
4 |
5 | // Login user
6 | export async function login({ username, password }) {
7 | const user = await db.user.findUnique({
8 | where: {
9 | username,
10 | },
11 | })
12 |
13 | if (!user) return null
14 |
15 | // Check password
16 | const isCorrectPassword = await bcrypt.compare(password, user.passwordHash)
17 |
18 | if (!isCorrectPassword) return null
19 |
20 | return user
21 | }
22 |
23 | // Register new user
24 | export async function register({ username, password }) {
25 | const passwordHash = await bcrypt.hash(password, 10)
26 | return db.user.create({
27 | data: {
28 | username,
29 | passwordHash,
30 | },
31 | })
32 | }
33 |
34 | // Get session secret
35 | const sessionSecret = process.env.SESSION_SECRET
36 | if (!sessionSecret) {
37 | throw new Error('No session secret')
38 | }
39 |
40 | // Create session storage
41 | const storage = createCookieSessionStorage({
42 | cookie: {
43 | name: 'remixblog_session',
44 | secure: process.env.NODE_ENV === 'production',
45 | secrets: [sessionSecret],
46 | sameSite: 'lax',
47 | path: '/',
48 | maxAge: 60 * 60 * 24 * 60,
49 | httpOnly: true,
50 | },
51 | })
52 |
53 | // Create user session
54 | export async function createUserSession(userId: string, redirectTo: string) {
55 | const session = await storage.getSession()
56 | session.set('userId', userId)
57 | return redirect(redirectTo, {
58 | headers: {
59 | 'Set-Cookie': await storage.commitSession(session),
60 | },
61 | })
62 | }
63 |
64 | // Get user session
65 | export function getUserSession(request: Request) {
66 | return storage.getSession(request.headers.get('Cookie'))
67 | }
68 |
69 | // Get logged in user
70 | export async function getUser(request: Request) {
71 | const session = await getUserSession(request)
72 | const userId = session.get('userId')
73 | if (!userId || typeof userId !== 'string') {
74 | return null
75 | }
76 |
77 | try {
78 | const user = await db.user.findUnique({ where: { id: userId } })
79 | return user
80 | } catch (error) {
81 | return null
82 | }
83 | }
84 |
85 | // Logout user and destroy session
86 | export async function logout(request: Request) {
87 | const session = await storage.getSession(request.headers.get('Cookie'))
88 | return redirect('/auth/logout', {
89 | headers: {
90 | 'Set-Cookie': await storage.destroySession(session),
91 | },
92 | })
93 | }
94 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "~/*": ["./app/*"]
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "remix-app-template-js",
4 | "description": "",
5 | "license": "",
6 | "prisma": {
7 | "seed": "node prisma/seed"
8 | },
9 | "scripts": {
10 | "build": "remix build",
11 | "dev": "remix dev",
12 | "postinstall": "remix setup node",
13 | "start": "remix-serve build"
14 | },
15 | "dependencies": {
16 | "@prisma/client": "^3.6.0",
17 | "@remix-run/react": "^1.0.6",
18 | "@remix-run/serve": "^1.0.6",
19 | "bcrypt": "^5.0.1",
20 | "prisma": "^3.6.0",
21 | "react": "^17.0.2",
22 | "react-dom": "^17.0.2",
23 | "remix": "^1.0.6"
24 | },
25 | "devDependencies": {
26 | "@remix-run/dev": "^1.0.6"
27 | },
28 | "engines": {
29 | "node": ">=14"
30 | },
31 | "sideEffects": false
32 | }
33 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "sqlite"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model User {
14 | id String @id @default(uuid())
15 | username String @unique
16 | passwordHash String
17 | createdAt DateTime @default(now())
18 | updatedAt DateTime @updatedAt
19 | posts Post[]
20 | }
21 |
22 | model Post {
23 | id String @id @default(uuid())
24 | userId String
25 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
26 | title String
27 | body String
28 | createdAt DateTime @default(now())
29 | updatedAt DateTime @updatedAt
30 | }
--------------------------------------------------------------------------------
/prisma/seed.js:
--------------------------------------------------------------------------------
1 | const { PrismaClient } = require('@prisma/client')
2 | const prisma = new PrismaClient()
3 |
4 | async function seed() {
5 | const john = await prisma.user.create({
6 | data: {
7 | username: 'john',
8 | // Hash for password - twixrox
9 | passwordHash:
10 | '$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u',
11 | },
12 | })
13 |
14 | await Promise.all(
15 | getPosts().map((post) => {
16 | const data = { userId: john.id, ...post }
17 | return prisma.post.create({ data })
18 | })
19 | )
20 | }
21 |
22 | seed()
23 |
24 | function getPosts() {
25 | return [
26 | {
27 | title: 'JavaScript Performance Tips',
28 | body: `We will look at 10 simple tips and tricks to increase the speed of your code when writing JS`,
29 | },
30 | {
31 | title: 'Tailwind vs. Bootstrap',
32 | body: `Both Tailwind and Bootstrap are very popular CSS frameworks. In this article, we will compare them`,
33 | },
34 | {
35 | title: 'Writing Great Unit Tests',
36 | body: `We will look at 10 simple tips and tricks on writing unit tests in JavaScript`,
37 | },
38 | {
39 | title: 'What Is New In PHP 8?',
40 | body: `In this article we will look at some of the new features offered in version 8 of PHP`,
41 | },
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bradtraversy/remix-blog/e83bbdcafea5fe7f410c53fdc69d859b9567e515/public/favicon.ico
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------