├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── README.md ├── app ├── entry.server.tsx ├── root.tsx └── routes │ ├── _index.tsx │ ├── article.$slug.tsx │ ├── editor.$slug.tsx │ ├── editor._index.tsx │ ├── login.tsx │ └── register.tsx ├── mocks ├── handlers.ts └── node.ts ├── package.json ├── pages ├── article-edit │ ├── api │ │ ├── action.ts │ │ └── loader.ts │ ├── index.ts │ ├── model │ │ └── parseAsArticle.ts │ └── ui │ │ ├── ArticleEditPage.tsx │ │ ├── FormErrors.tsx │ │ └── TagsInput.tsx ├── article-read │ ├── api │ │ ├── action.ts │ │ └── loader.ts │ ├── index.ts │ └── ui │ │ ├── ArticleMeta.tsx │ │ ├── ArticleReadPage.tsx │ │ └── Comments.tsx ├── feed │ ├── api │ │ └── loader.ts │ ├── index.ts │ └── ui │ │ ├── ArticlePreview.tsx │ │ ├── FeedPage.tsx │ │ ├── Pagination.tsx │ │ ├── PopularTags.tsx │ │ └── Tabs.tsx └── sign-in │ ├── api │ ├── register.ts │ └── sign-in.ts │ ├── index.ts │ └── ui │ ├── RegisterPage.tsx │ └── SignInPage.tsx ├── pnpm-lock.yaml ├── public └── favicon.ico ├── remix.config.js ├── shared ├── api │ ├── auth.server.ts │ ├── client.ts │ ├── currentUser.ts │ ├── index.ts │ └── models.ts ├── config │ ├── backend.ts │ └── index.ts └── ui │ ├── Header.tsx │ └── index.ts ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | SESSION_SECRET=required-random-string 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | 23 | // Base config 24 | extends: ["eslint:recommended"], 25 | 26 | overrides: [ 27 | // React 28 | { 29 | files: ["**/*.{js,jsx,ts,tsx}"], 30 | plugins: ["react", "jsx-a11y"], 31 | extends: [ 32 | "plugin:react/recommended", 33 | "plugin:react/jsx-runtime", 34 | "plugin:react-hooks/recommended", 35 | "plugin:jsx-a11y/recommended", 36 | ], 37 | settings: { 38 | react: { 39 | version: "detect", 40 | }, 41 | formComponents: ["Form"], 42 | linkComponents: [ 43 | { name: "Link", linkAttribute: "to" }, 44 | { name: "NavLink", linkAttribute: "to" }, 45 | ], 46 | "import/resolver": { 47 | typescript: {}, 48 | }, 49 | }, 50 | }, 51 | 52 | // Typescript 53 | { 54 | files: ["**/*.{ts,tsx}"], 55 | plugins: ["@typescript-eslint", "import"], 56 | parser: "@typescript-eslint/parser", 57 | settings: { 58 | "import/internal-regex": "^~/", 59 | "import/resolver": { 60 | node: { 61 | extensions: [".ts", ".tsx"], 62 | }, 63 | typescript: { 64 | alwaysTryTypes: true, 65 | }, 66 | }, 67 | }, 68 | extends: [ 69 | "plugin:@typescript-eslint/recommended", 70 | "plugin:import/recommended", 71 | "plugin:import/typescript", 72 | ], 73 | }, 74 | 75 | // Node 76 | { 77 | files: [".eslintrc.js"], 78 | env: { 79 | node: true, 80 | }, 81 | }, 82 | ], 83 | }; 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | 8 | shared/api/v1.d.ts 9 | local.db 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "node:stream"; 2 | 3 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 4 | import { createReadableStreamFromReadable } from "@remix-run/node"; 5 | import { RemixServer } from "@remix-run/react"; 6 | import * as isbotModule from "isbot"; 7 | import { renderToPipeableStream } from "react-dom/server"; 8 | import { applyMigrations, seed } from "realworld-hono-drizzle"; 9 | 10 | import { server } from "../mocks/node"; 11 | 12 | await applyMigrations("file:local.db"); 13 | await seed('file:local.db'); 14 | server.listen(); 15 | 16 | const ABORT_DELAY = 5_000; 17 | 18 | export default function handleRequest( 19 | request: Request, 20 | responseStatusCode: number, 21 | responseHeaders: Headers, 22 | remixContext: EntryContext, 23 | loadContext: AppLoadContext 24 | ) { 25 | let prohibitOutOfOrderStreaming = 26 | isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode; 27 | 28 | return prohibitOutOfOrderStreaming 29 | ? handleBotRequest( 30 | request, 31 | responseStatusCode, 32 | responseHeaders, 33 | remixContext 34 | ) 35 | : handleBrowserRequest( 36 | request, 37 | responseStatusCode, 38 | responseHeaders, 39 | remixContext 40 | ); 41 | } 42 | 43 | // We have some Remix apps in the wild already running with isbot@3 so we need 44 | // to maintain backwards compatibility even though we want new apps to use 45 | // isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev. 46 | function isBotRequest(userAgent: string | null) { 47 | if (!userAgent) { 48 | return false; 49 | } 50 | 51 | // isbot >= 3.8.0, >4 52 | if ("isbot" in isbotModule && typeof isbotModule.isbot === "function") { 53 | return isbotModule.isbot(userAgent); 54 | } 55 | 56 | // isbot < 3.8.0 57 | if ("default" in isbotModule && typeof isbotModule.default === "function") { 58 | return isbotModule.default(userAgent); 59 | } 60 | 61 | return false; 62 | } 63 | 64 | function handleBotRequest( 65 | request: Request, 66 | responseStatusCode: number, 67 | responseHeaders: Headers, 68 | remixContext: EntryContext 69 | ) { 70 | return new Promise((resolve, reject) => { 71 | let shellRendered = false; 72 | const { pipe, abort } = renderToPipeableStream( 73 | , 78 | { 79 | onAllReady() { 80 | shellRendered = true; 81 | const body = new PassThrough(); 82 | const stream = createReadableStreamFromReadable(body); 83 | 84 | responseHeaders.set("Content-Type", "text/html"); 85 | 86 | resolve( 87 | new Response(stream, { 88 | headers: responseHeaders, 89 | status: responseStatusCode, 90 | }) 91 | ); 92 | 93 | pipe(body); 94 | }, 95 | onShellError(error: unknown) { 96 | reject(error); 97 | }, 98 | onError(error: unknown) { 99 | responseStatusCode = 500; 100 | // Log streaming rendering errors from inside the shell. Don't log 101 | // errors encountered during initial shell rendering since they'll 102 | // reject and get logged in handleDocumentRequest. 103 | if (shellRendered) { 104 | console.error(error); 105 | } 106 | }, 107 | } 108 | ); 109 | 110 | setTimeout(abort, ABORT_DELAY); 111 | }); 112 | } 113 | 114 | function handleBrowserRequest( 115 | request: Request, 116 | responseStatusCode: number, 117 | responseHeaders: Headers, 118 | remixContext: EntryContext 119 | ) { 120 | return new Promise((resolve, reject) => { 121 | let shellRendered = false; 122 | const { pipe, abort } = renderToPipeableStream( 123 | , 128 | { 129 | onShellReady() { 130 | shellRendered = true; 131 | const body = new PassThrough(); 132 | const stream = createReadableStreamFromReadable(body); 133 | 134 | responseHeaders.set("Content-Type", "text/html"); 135 | 136 | resolve( 137 | new Response(stream, { 138 | headers: responseHeaders, 139 | status: responseStatusCode, 140 | }) 141 | ); 142 | 143 | pipe(body); 144 | }, 145 | onShellError(error: unknown) { 146 | reject(error); 147 | }, 148 | onError(error: unknown) { 149 | responseStatusCode = 500; 150 | // Log streaming rendering errors from inside the shell. Don't log 151 | // errors encountered during initial shell rendering since they'll 152 | // reject and get logged in handleDocumentRequest. 153 | if (shellRendered) { 154 | console.error(error); 155 | } 156 | }, 157 | } 158 | ); 159 | 160 | setTimeout(abort, ABORT_DELAY); 161 | }); 162 | } 163 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node"; 2 | import { 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | useLoaderData, 9 | } from "@remix-run/react"; 10 | 11 | import { Header } from "shared/ui"; 12 | import { getUserFromSession, CurrentUser } from "shared/api"; 13 | 14 | export const loader = ({ request }: LoaderFunctionArgs) => 15 | getUserFromSession(request); 16 | 17 | export default function App() { 18 | const user = useLoaderData(); 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 32 | 37 | 38 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | import { FeedPage } from "pages/feed"; 3 | 4 | export { loader } from "pages/feed"; 5 | 6 | export const meta: MetaFunction = () => { 7 | return [{ title: "Conduit" }]; 8 | }; 9 | 10 | export default FeedPage; 11 | -------------------------------------------------------------------------------- /app/routes/article.$slug.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleReadPage } from "pages/article-read"; 2 | 3 | export { loader, action } from "pages/article-read"; 4 | 5 | export default ArticleReadPage; 6 | -------------------------------------------------------------------------------- /app/routes/editor.$slug.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleEditPage } from "pages/article-edit"; 2 | 3 | export { loader, action } from "pages/article-edit"; 4 | 5 | export default ArticleEditPage; 6 | -------------------------------------------------------------------------------- /app/routes/editor._index.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleEditPage } from "pages/article-edit"; 2 | 3 | export { loader, action } from "pages/article-edit"; 4 | 5 | export default ArticleEditPage; 6 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { SignInPage, signIn } from "pages/sign-in"; 2 | 3 | export { signIn as action }; 4 | 5 | export default SignInPage; 6 | -------------------------------------------------------------------------------- /app/routes/register.tsx: -------------------------------------------------------------------------------- 1 | import { RegisterPage, register } from "pages/sign-in"; 2 | 3 | export { register as action }; 4 | 5 | export default RegisterPage; 6 | -------------------------------------------------------------------------------- /mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from "node:crypto"; 2 | import { http } from "msw"; 3 | import { backendBaseUrl } from "shared/config"; 4 | import realWorldApp from "realworld-hono-drizzle"; 5 | 6 | const bindings = { 7 | DATABASE_URL: "file:local.db", 8 | JWT_SECRET: randomBytes(64).toString("base64url"), 9 | }; 10 | 11 | export const handlers = [ 12 | http.all(`${backendBaseUrl.replace(/\/$/, '')}/*`, ({ request }) => { 13 | return realWorldApp.fetch(request, bindings); 14 | }), 15 | ]; 16 | -------------------------------------------------------------------------------- /mocks/node.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | export const server = setupServer(...handlers); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tutorial-conduit", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "remix vite:dev", 9 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 10 | "start": "remix-serve ./build/server/index.js", 11 | "generate-api-types": "openapi-typescript https://raw.githubusercontent.com/gothinkster/realworld/main/api/openapi.yml --output ./shared/api/v1.d.ts", 12 | "typecheck": "tsc" 13 | }, 14 | "dependencies": { 15 | "@remix-run/node": "^2.16.0", 16 | "@remix-run/react": "^2.16.0", 17 | "@remix-run/serve": "^2.16.0", 18 | "isbot": "^4.4.0", 19 | "openapi-fetch": "^0.9.5", 20 | "react": "^18.3.1", 21 | "react-dom": "^18.3.1", 22 | "remix-utils": "^7.6.0" 23 | }, 24 | "devDependencies": { 25 | "@feature-sliced/cli": "^1.0.0", 26 | "@remix-run/dev": "^2.9.1", 27 | "@types/react": "^18.3.1", 28 | "@types/react-dom": "^18.3.0", 29 | "@typescript-eslint/eslint-plugin": "^6.21.0", 30 | "eslint": "^8.57.0", 31 | "eslint-config-prettier": "^9.1.0", 32 | "eslint-import-resolver-typescript": "^3.6.1", 33 | "eslint-plugin-import": "^2.29.1", 34 | "eslint-plugin-jsx-a11y": "^6.8.0", 35 | "eslint-plugin-react": "^7.34.1", 36 | "eslint-plugin-react-hooks": "^4.6.2", 37 | "msw": "^2.7.3", 38 | "openapi-typescript": "^6.7.5", 39 | "prettier": "^3.2.5", 40 | "realworld-hono-drizzle": "^1.0.5", 41 | "tiny-invariant": "^1.3.3", 42 | "typescript": "^5.4.5", 43 | "vite": "^6.2.0", 44 | "vite-tsconfig-paths": "^5.1.4" 45 | }, 46 | "engines": { 47 | "node": ">=18.0.0" 48 | }, 49 | "pnpm": { 50 | "onlyBuiltDependencies": [ 51 | "esbuild", 52 | "msw" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/article-edit/api/action.ts: -------------------------------------------------------------------------------- 1 | import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; 2 | 3 | import { POST, PUT, requireUser } from "shared/api"; 4 | import { parseAsArticle } from "../model/parseAsArticle"; 5 | 6 | export const action = async ({ request, params }: ActionFunctionArgs) => { 7 | try { 8 | const { body, description, title, tags } = parseAsArticle( 9 | await request.formData(), 10 | ); 11 | const tagList = tags?.split(",") ?? []; 12 | 13 | const currentUser = await requireUser(request); 14 | const payload = { 15 | body: { 16 | article: { 17 | title, 18 | description, 19 | body, 20 | tagList, 21 | }, 22 | }, 23 | headers: { Authorization: `Token ${currentUser.token}` }, 24 | }; 25 | 26 | const { data, error } = await (params.slug 27 | ? PUT("/articles/{slug}", { 28 | params: { path: { slug: params.slug } }, 29 | ...payload, 30 | }) 31 | : POST("/articles", payload)); 32 | 33 | if (error) { 34 | return json({ errors: error }, { status: 422 }); 35 | } 36 | 37 | return redirect(`/article/${data.article.slug ?? ""}`); 38 | } catch (errors) { 39 | return json({ errors }, { status: 400 }); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /pages/article-edit/api/loader.ts: -------------------------------------------------------------------------------- 1 | import { json, type LoaderFunctionArgs } from "@remix-run/node"; 2 | import type { FetchResponse } from "openapi-fetch"; 3 | 4 | import { GET, requireUser } from "shared/api"; 5 | 6 | async function throwAnyErrors( 7 | responsePromise: Promise>, 8 | ) { 9 | const { data, error, response } = await responsePromise; 10 | 11 | if (error !== undefined) { 12 | throw json(error, { status: response.status }); 13 | } 14 | 15 | return data as NonNullable; 16 | } 17 | 18 | export const loader = async ({ params, request }: LoaderFunctionArgs) => { 19 | const currentUser = await requireUser(request); 20 | 21 | if (!params.slug) { 22 | return { article: null }; 23 | } 24 | 25 | return throwAnyErrors( 26 | GET("/articles/{slug}", { 27 | params: { path: { slug: params.slug } }, 28 | headers: { Authorization: `Token ${currentUser.token}` }, 29 | }), 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /pages/article-edit/index.ts: -------------------------------------------------------------------------------- 1 | export { ArticleEditPage } from "./ui/ArticleEditPage"; 2 | export { loader } from "./api/loader"; 3 | export { action } from "./api/action"; 4 | -------------------------------------------------------------------------------- /pages/article-edit/model/parseAsArticle.ts: -------------------------------------------------------------------------------- 1 | export function parseAsArticle(data: FormData) { 2 | const errors = []; 3 | 4 | const title = data.get("title"); 5 | if (typeof title !== "string" || title === "") { 6 | errors.push("Give this article a title"); 7 | } 8 | 9 | const description = data.get("description"); 10 | if (typeof description !== "string" || description === "") { 11 | errors.push("Describe what this article is about"); 12 | } 13 | 14 | const body = data.get("body"); 15 | if (typeof body !== "string" || body === "") { 16 | errors.push("Write the article itself"); 17 | } 18 | 19 | const tags = data.get("tags"); 20 | if (typeof tags !== "string") { 21 | errors.push("The tags must be a string"); 22 | } 23 | 24 | if (errors.length > 0) { 25 | throw errors; 26 | } 27 | 28 | return { title, description, body, tags: data.get("tags") ?? "" } as { 29 | title: string; 30 | description: string; 31 | body: string; 32 | tags: string; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /pages/article-edit/ui/ArticleEditPage.tsx: -------------------------------------------------------------------------------- 1 | import { Form, useLoaderData } from "@remix-run/react"; 2 | 3 | import type { loader } from "../api/loader"; 4 | import { TagsInput } from "./TagsInput"; 5 | import { FormErrors } from "./FormErrors"; 6 | 7 | export function ArticleEditPage() { 8 | const article = useLoaderData(); 9 | 10 | return ( 11 |
12 |
13 |
14 |
15 | 16 | 17 |
18 |
19 |
20 | 27 |
28 |
29 | 36 |
37 |
38 | 45 |
46 |
47 | 51 |
52 | 53 | 56 |
57 |
58 |
59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /pages/article-edit/ui/FormErrors.tsx: -------------------------------------------------------------------------------- 1 | import { useActionData } from "@remix-run/react"; 2 | import type { action } from "../api/action"; 3 | 4 | export function FormErrors() { 5 | const actionData = useActionData(); 6 | 7 | return actionData?.errors != null ? ( 8 |
    9 | {actionData.errors.map((error) => ( 10 |
  • {error}
  • 11 | ))} 12 |
13 | ) : null; 14 | } 15 | -------------------------------------------------------------------------------- /pages/article-edit/ui/TagsInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | export function TagsInput({ 4 | name, 5 | defaultValue, 6 | }: { 7 | name: string; 8 | defaultValue?: Array; 9 | }) { 10 | const [tagListState, setTagListState] = useState(defaultValue ?? []); 11 | 12 | function removeTag(tag: string): void { 13 | const newTagList = tagListState.filter((t) => t !== tag); 14 | setTagListState(newTagList); 15 | } 16 | 17 | const tagsInput = useRef(null); 18 | useEffect(() => { 19 | tagsInput.current && (tagsInput.current.value = tagListState.join(",")); 20 | }, [tagListState]); 21 | 22 | return ( 23 | <> 24 | 32 | setTagListState(e.target.value.split(",").filter(Boolean)) 33 | } 34 | /> 35 |
36 | {tagListState.map((tag) => ( 37 | 38 | 43 | [" ", "Enter"].includes(e.key) && removeTag(tag) 44 | } 45 | onClick={() => removeTag(tag)} 46 | >{" "} 47 | {tag} 48 | 49 | ))} 50 |
51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /pages/article-read/api/action.ts: -------------------------------------------------------------------------------- 1 | import { redirect, type ActionFunctionArgs } from "@remix-run/node"; 2 | import { namedAction } from "remix-utils/named-action"; 3 | import { redirectBack } from "remix-utils/redirect-back"; 4 | import invariant from "tiny-invariant"; 5 | 6 | import { DELETE, POST, requireUser } from "shared/api"; 7 | 8 | export const action = async ({ request, params }: ActionFunctionArgs) => { 9 | const currentUser = await requireUser(request); 10 | 11 | const authorization = { Authorization: `Token ${currentUser.token}` }; 12 | 13 | const formData = await request.formData(); 14 | 15 | return namedAction(formData, { 16 | async delete() { 17 | invariant(params.slug, "Expected a slug parameter"); 18 | await DELETE("/articles/{slug}", { 19 | params: { path: { slug: params.slug } }, 20 | headers: authorization, 21 | }); 22 | return redirect("/"); 23 | }, 24 | async favorite() { 25 | invariant(params.slug, "Expected a slug parameter"); 26 | await POST("/articles/{slug}/favorite", { 27 | params: { path: { slug: params.slug } }, 28 | headers: authorization, 29 | }); 30 | return redirectBack(request, { fallback: "/" }); 31 | }, 32 | async unfavorite() { 33 | invariant(params.slug, "Expected a slug parameter"); 34 | await DELETE("/articles/{slug}/favorite", { 35 | params: { path: { slug: params.slug } }, 36 | headers: authorization, 37 | }); 38 | return redirectBack(request, { fallback: "/" }); 39 | }, 40 | async createComment() { 41 | invariant(params.slug, "Expected a slug parameter"); 42 | const comment = formData.get("comment"); 43 | invariant(typeof comment === "string", "Expected a comment parameter"); 44 | await POST("/articles/{slug}/comments", { 45 | params: { path: { slug: params.slug } }, 46 | headers: { ...authorization, "Content-Type": "application/json" }, 47 | body: { comment: { body: comment } }, 48 | }); 49 | return redirectBack(request, { fallback: "/" }); 50 | }, 51 | async deleteComment() { 52 | invariant(params.slug, "Expected a slug parameter"); 53 | const commentId = formData.get("id"); 54 | invariant(typeof commentId === "string", "Expected an id parameter"); 55 | const commentIdNumeric = parseInt(commentId, 10); 56 | invariant( 57 | !Number.isNaN(commentIdNumeric), 58 | "Expected a numeric id parameter", 59 | ); 60 | await DELETE("/articles/{slug}/comments/{id}", { 61 | params: { path: { slug: params.slug, id: commentIdNumeric } }, 62 | headers: authorization, 63 | }); 64 | return redirectBack(request, { fallback: "/" }); 65 | }, 66 | async followAuthor() { 67 | const authorUsername = formData.get("username"); 68 | invariant( 69 | typeof authorUsername === "string", 70 | "Expected a username parameter", 71 | ); 72 | await POST("/profiles/{username}/follow", { 73 | params: { path: { username: authorUsername } }, 74 | headers: authorization, 75 | }); 76 | return redirectBack(request, { fallback: "/" }); 77 | }, 78 | async unfollowAuthor() { 79 | const authorUsername = formData.get("username"); 80 | invariant( 81 | typeof authorUsername === "string", 82 | "Expected a username parameter", 83 | ); 84 | await DELETE("/profiles/{username}/follow", { 85 | params: { path: { username: authorUsername } }, 86 | headers: authorization, 87 | }); 88 | return redirectBack(request, { fallback: "/" }); 89 | }, 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /pages/article-read/api/loader.ts: -------------------------------------------------------------------------------- 1 | import { json, type LoaderFunctionArgs } from "@remix-run/node"; 2 | import invariant from "tiny-invariant"; 3 | import type { FetchResponse } from "openapi-fetch"; 4 | import { promiseHash } from "remix-utils/promise"; 5 | 6 | import { GET, getUserFromSession } from "shared/api"; 7 | 8 | async function throwAnyErrors( 9 | responsePromise: Promise>, 10 | ) { 11 | const { data, error, response } = await responsePromise; 12 | 13 | if (error !== undefined) { 14 | throw json(error, { status: response.status }); 15 | } 16 | 17 | return data as NonNullable; 18 | } 19 | 20 | export const loader = async ({ request, params }: LoaderFunctionArgs) => { 21 | invariant(params.slug, "Expected a slug parameter"); 22 | const currentUser = await getUserFromSession(request); 23 | const authorization = currentUser 24 | ? { Authorization: `Token ${currentUser.token}` } 25 | : undefined; 26 | 27 | return json( 28 | await promiseHash({ 29 | article: throwAnyErrors( 30 | GET("/articles/{slug}", { 31 | params: { 32 | path: { slug: params.slug }, 33 | }, 34 | headers: authorization, 35 | }), 36 | ), 37 | comments: throwAnyErrors( 38 | GET("/articles/{slug}/comments", { 39 | params: { 40 | path: { slug: params.slug }, 41 | }, 42 | headers: authorization, 43 | }), 44 | ), 45 | }), 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /pages/article-read/index.ts: -------------------------------------------------------------------------------- 1 | export { ArticleReadPage } from "./ui/ArticleReadPage"; 2 | export { loader } from "./api/loader"; 3 | export { action } from "./api/action"; 4 | -------------------------------------------------------------------------------- /pages/article-read/ui/ArticleMeta.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useLoaderData } from "@remix-run/react"; 2 | import { useContext } from "react"; 3 | 4 | import { CurrentUser } from "shared/api"; 5 | import type { loader } from "../api/loader"; 6 | 7 | export function ArticleMeta() { 8 | const currentUser = useContext(CurrentUser); 9 | const { article } = useLoaderData(); 10 | 11 | return ( 12 |
13 |
14 | 18 | 19 | 20 | 21 |
22 | 27 | {article.article.author.username} 28 | 29 | {article.article.createdAt} 30 |
31 | 32 | {article.article.author.username == currentUser?.username ? ( 33 | <> 34 | 39 | Edit Article 40 | 41 |    42 | 49 | 50 | ) : ( 51 | <> 52 | 57 | 73 |    74 | 88 | 89 | )} 90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /pages/article-read/ui/ArticleReadPage.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "@remix-run/react"; 2 | 3 | import type { loader } from "../api/loader"; 4 | import { ArticleMeta } from "./ArticleMeta"; 5 | import { Comments } from "./Comments"; 6 | 7 | export function ArticleReadPage() { 8 | const { article } = useLoaderData(); 9 | 10 | return ( 11 |
12 |
13 |
14 |

{article.article.title}

15 | 16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 |

{article.article.body}

24 |
    25 | {article.article.tagList.map((tag) => ( 26 |
  • 27 | {tag} 28 |
  • 29 | ))} 30 |
31 |
32 |
33 | 34 |
35 | 36 |
37 | 38 |
39 | 40 |
41 | 42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /pages/article-read/ui/Comments.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Form, Link, useLoaderData } from "@remix-run/react"; 3 | 4 | import { CurrentUser } from "shared/api"; 5 | import type { loader } from "../api/loader"; 6 | 7 | export function Comments() { 8 | const { comments } = useLoaderData(); 9 | const currentUser = useContext(CurrentUser); 10 | 11 | return ( 12 |
13 | {currentUser !== null ? ( 14 |
19 |
20 | 27 |
28 |
29 | 34 | 41 |
42 |
43 | ) : ( 44 |
45 |
46 |

47 | Sign in 48 |   or   49 | Sign up 50 |   to add comments on this article. 51 |

52 |
53 |
54 | )} 55 | 56 | {comments.comments.map((comment) => ( 57 |
58 |
59 |

{comment.body}

60 |
61 | 62 |
63 | 67 | 72 | 73 |   74 | 78 | {comment.author.username} 79 | 80 | {comment.createdAt} 81 | {comment.author.username === currentUser?.username && ( 82 | 83 |
84 | 85 | 96 |
97 |
98 | )} 99 |
100 |
101 | ))} 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /pages/feed/api/loader.ts: -------------------------------------------------------------------------------- 1 | import { json, type LoaderFunctionArgs } from "@remix-run/node"; 2 | import type { FetchResponse } from "openapi-fetch"; 3 | import { promiseHash } from "remix-utils/promise"; 4 | 5 | import { GET, requireUser } from "shared/api"; 6 | 7 | async function throwAnyErrors( 8 | responsePromise: Promise>, 9 | ) { 10 | const { data, error, response } = await responsePromise; 11 | 12 | if (error !== undefined) { 13 | throw json(error, { status: response.status }); 14 | } 15 | 16 | return data as NonNullable; 17 | } 18 | 19 | /** Amount of articles on one page. */ 20 | export const LIMIT = 20; 21 | 22 | export const loader = async ({ request }: LoaderFunctionArgs) => { 23 | const url = new URL(request.url); 24 | const selectedTag = url.searchParams.get("tag") ?? undefined; 25 | const page = parseInt(url.searchParams.get("page") ?? "", 10); 26 | 27 | if (url.searchParams.get("source") === "my-feed") { 28 | const userSession = await requireUser(request); 29 | 30 | return json( 31 | await promiseHash({ 32 | articles: throwAnyErrors( 33 | GET("/articles/feed", { 34 | params: { 35 | query: { 36 | limit: LIMIT, 37 | offset: !Number.isNaN(page) ? page * LIMIT : undefined, 38 | }, 39 | }, 40 | headers: { Authorization: `Token ${userSession.token}` }, 41 | }), 42 | ), 43 | tags: throwAnyErrors(GET("/tags")), 44 | }), 45 | ); 46 | } 47 | 48 | return json( 49 | await promiseHash({ 50 | articles: throwAnyErrors( 51 | GET("/articles", { 52 | params: { 53 | query: { 54 | tag: selectedTag, 55 | limit: LIMIT, 56 | offset: !Number.isNaN(page) ? page * LIMIT : undefined, 57 | }, 58 | }, 59 | }), 60 | ), 61 | tags: throwAnyErrors(GET("/tags")), 62 | }), 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /pages/feed/index.ts: -------------------------------------------------------------------------------- 1 | export { FeedPage } from "./ui/FeedPage"; 2 | export { loader } from "./api/loader"; 3 | -------------------------------------------------------------------------------- /pages/feed/ui/ArticlePreview.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link } from "@remix-run/react"; 2 | import type { Article } from "shared/api"; 3 | 4 | interface ArticlePreviewProps { 5 | article: Article; 6 | } 7 | 8 | export function ArticlePreview({ article }: ArticlePreviewProps) { 9 | return ( 10 |
11 |
12 | 13 | 14 | 15 |
16 | 21 | {article.author.username} 22 | 23 | 24 | {new Date(article.createdAt).toLocaleDateString(undefined, { 25 | dateStyle: "long", 26 | })} 27 | 28 |
29 |
34 | 41 |
42 |
43 | 48 |

{article.title}

49 |

{article.description}

50 | Read more... 51 |
    52 | {article.tagList.map((tag) => ( 53 |
  • 54 | {tag} 55 |
  • 56 | ))} 57 |
58 | 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /pages/feed/ui/FeedPage.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "@remix-run/react"; 2 | 3 | import type { loader } from "../api/loader"; 4 | import { ArticlePreview } from "./ArticlePreview"; 5 | import { Tabs } from "./Tabs"; 6 | import { PopularTags } from "./PopularTags"; 7 | import { Pagination } from "./Pagination"; 8 | 9 | export function FeedPage() { 10 | const { articles } = useLoaderData(); 11 | 12 | return ( 13 |
14 |
15 |
16 |

conduit

17 |

A place to share your knowledge.

18 |
19 |
20 | 21 |
22 |
23 |
24 | 25 | 26 | {articles.articles.map((article) => ( 27 | 28 | ))} 29 | 30 | 31 |
32 | 33 |
34 | 35 |
36 |
37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /pages/feed/ui/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; 2 | import { ExistingSearchParams } from "remix-utils/existing-search-params"; 3 | 4 | import { LIMIT, type loader } from "../api/loader"; 5 | 6 | export function Pagination() { 7 | const [searchParams] = useSearchParams(); 8 | const { articles } = useLoaderData(); 9 | const pageAmount = Math.ceil(articles.articlesCount / LIMIT); 10 | const currentPage = parseInt(searchParams.get("page") ?? "1", 10); 11 | 12 | return ( 13 |
14 | 15 |
    16 | {Array(pageAmount) 17 | .fill(null) 18 | .map((_, index) => 19 | index + 1 === currentPage ? ( 20 |
  • 21 | {index + 1} 22 |
  • 23 | ) : ( 24 |
  • 25 | 28 |
  • 29 | ), 30 | )} 31 |
32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /pages/feed/ui/PopularTags.tsx: -------------------------------------------------------------------------------- 1 | import { Form, useLoaderData } from "@remix-run/react"; 2 | import { ExistingSearchParams } from "remix-utils/existing-search-params"; 3 | 4 | import type { loader } from "../api/loader"; 5 | 6 | export function PopularTags() { 7 | const { tags } = useLoaderData(); 8 | 9 | return ( 10 |
11 |

Popular Tags

12 | 13 |
14 | 15 |
16 | {tags.tags.map((tag) => ( 17 | 25 | ))} 26 |
27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /pages/feed/ui/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Form, useSearchParams } from "@remix-run/react"; 3 | 4 | import { CurrentUser } from "shared/api"; 5 | 6 | export function Tabs() { 7 | const [searchParams] = useSearchParams(); 8 | const currentUser = useContext(CurrentUser); 9 | 10 | return ( 11 |
12 |
13 |
    14 | {currentUser !== null && ( 15 |
  • 16 | 23 |
  • 24 | )} 25 |
  • 26 | 31 |
  • 32 | {searchParams.has("tag") && ( 33 |
  • 34 | 35 | {searchParams.get("tag")} 36 | 37 |
  • 38 | )} 39 |
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /pages/sign-in/api/register.ts: -------------------------------------------------------------------------------- 1 | import { json, type ActionFunctionArgs } from "@remix-run/node"; 2 | 3 | import { POST, createUserSession } from "shared/api"; 4 | 5 | export const register = async ({ request }: ActionFunctionArgs) => { 6 | const formData = await request.formData(); 7 | const username = formData.get("username")?.toString() ?? ""; 8 | const email = formData.get("email")?.toString() ?? ""; 9 | const password = formData.get("password")?.toString() ?? ""; 10 | 11 | const { data, error } = await POST("/users", { 12 | body: { user: { email, password, username } }, 13 | }); 14 | 15 | if (error) { 16 | return json({ error }, { status: 400 }); 17 | } else { 18 | return createUserSession({ 19 | request: request, 20 | user: data.user, 21 | redirectTo: "/", 22 | }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /pages/sign-in/api/sign-in.ts: -------------------------------------------------------------------------------- 1 | import { json, type ActionFunctionArgs } from "@remix-run/node"; 2 | 3 | import { POST, createUserSession } from "shared/api"; 4 | 5 | export const signIn = async ({ request }: ActionFunctionArgs) => { 6 | const formData = await request.formData(); 7 | const email = formData.get("email")?.toString() ?? ""; 8 | const password = formData.get("password")?.toString() ?? ""; 9 | 10 | const { data, error } = await POST("/users/login", { 11 | body: { user: { email, password } }, 12 | }); 13 | 14 | if (error) { 15 | return json({ error }, { status: 400 }); 16 | } else { 17 | return createUserSession({ 18 | request: request, 19 | user: data.user, 20 | redirectTo: "/", 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /pages/sign-in/index.ts: -------------------------------------------------------------------------------- 1 | export { RegisterPage } from './ui/RegisterPage'; 2 | export { register } from './api/register'; 3 | export { SignInPage } from './ui/SignInPage'; 4 | export { signIn } from './api/sign-in'; 5 | -------------------------------------------------------------------------------- /pages/sign-in/ui/RegisterPage.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useActionData } from "@remix-run/react"; 2 | 3 | import type { register } from "../api/register"; 4 | 5 | export function RegisterPage() { 6 | const registerData = useActionData(); 7 | 8 | return ( 9 |
10 |
11 |
12 |
13 |

Sign up

14 |

15 | Have an account? 16 |

17 | 18 | {registerData?.error && ( 19 |
    20 | {registerData.error.errors.body.map((error) => ( 21 |
  • {error}
  • 22 | ))} 23 |
24 | )} 25 | 26 |
27 |
28 | 34 |
35 |
36 | 42 |
43 |
44 | 50 |
51 | 54 |
55 |
56 |
57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /pages/sign-in/ui/SignInPage.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useActionData } from "@remix-run/react"; 2 | 3 | import type { signIn } from "../api/sign-in"; 4 | 5 | export function SignInPage() { 6 | const signInData = useActionData(); 7 | 8 | return ( 9 |
10 |
11 |
12 |
13 |

Sign in

14 |

15 | Need an account? 16 |

17 | 18 | {signInData?.error && ( 19 |
    20 | {signInData.error.errors.body.map((error) => ( 21 |
  • {error}
  • 22 | ))} 23 |
24 | )} 25 | 26 |
27 |
28 | 34 |
35 |
36 | 42 |
43 | 46 |
47 |
48 |
49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feature-sliced/tutorial-conduit/93be09b26a8ed2ac0b047ab13723e963b67cd2e2/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | ignoredRouteFiles: ["**/.*"], 4 | watchPaths: ["shared", "pages"], 5 | // appDirectory: "app", 6 | // assetsBuildDirectory: "public/build", 7 | // publicPath: "/build/", 8 | // serverBuildPath: "build/index.js", 9 | }; 10 | -------------------------------------------------------------------------------- /shared/api/auth.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, redirect } from "@remix-run/node"; 2 | import invariant from "tiny-invariant"; 3 | 4 | import type { User } from "./models"; 5 | 6 | invariant( 7 | process.env.SESSION_SECRET, 8 | "SESSION_SECRET must be set for authentication to work. Did you rename .env.example to .env?", 9 | ); 10 | 11 | const sessionStorage = createCookieSessionStorage<{ 12 | user: User; 13 | }>({ 14 | cookie: { 15 | name: "__session", 16 | httpOnly: true, 17 | path: "/", 18 | sameSite: "lax", 19 | secrets: [process.env.SESSION_SECRET], 20 | secure: process.env.NODE_ENV === "production", 21 | }, 22 | }); 23 | 24 | export async function createUserSession({ 25 | request, 26 | user, 27 | redirectTo, 28 | }: { 29 | request: Request; 30 | user: User; 31 | redirectTo: string; 32 | }) { 33 | const cookie = request.headers.get("Cookie"); 34 | const session = await sessionStorage.getSession(cookie); 35 | 36 | session.set("user", user); 37 | 38 | return redirect(redirectTo, { 39 | headers: { 40 | "Set-Cookie": await sessionStorage.commitSession(session, { 41 | maxAge: 60 * 60 * 24 * 7, // 7 days 42 | }), 43 | }, 44 | }); 45 | } 46 | 47 | export async function getUserFromSession(request: Request) { 48 | const cookie = request.headers.get("Cookie"); 49 | const session = await sessionStorage.getSession(cookie); 50 | 51 | return session.get("user") ?? null; 52 | } 53 | 54 | export async function requireUser(request: Request) { 55 | const user = await getUserFromSession(request); 56 | 57 | if (user === null) { 58 | throw redirect("/login"); 59 | } 60 | 61 | return user; 62 | } 63 | -------------------------------------------------------------------------------- /shared/api/client.ts: -------------------------------------------------------------------------------- 1 | import createClient from "openapi-fetch"; 2 | 3 | import { backendBaseUrl } from "shared/config"; 4 | import type { paths } from "./v1"; 5 | 6 | export const { GET, POST, PUT, DELETE } = createClient({ baseUrl: backendBaseUrl }); 7 | 8 | -------------------------------------------------------------------------------- /shared/api/currentUser.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | import type { User } from "./models"; 4 | 5 | export const CurrentUser = createContext(null); 6 | -------------------------------------------------------------------------------- /shared/api/index.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST, PUT, DELETE } from "./client"; 2 | 3 | export type { Article } from "./models"; 4 | 5 | export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; 6 | export { CurrentUser } from "./currentUser"; 7 | -------------------------------------------------------------------------------- /shared/api/models.ts: -------------------------------------------------------------------------------- 1 | import type { components } from "./v1"; 2 | 3 | export type Article = components["schemas"]["Article"]; 4 | export type User = components["schemas"]["User"]; 5 | -------------------------------------------------------------------------------- /shared/config/backend.ts: -------------------------------------------------------------------------------- 1 | export const backendBaseUrl = "https://api.realworld.io/api"; 2 | -------------------------------------------------------------------------------- /shared/config/index.ts: -------------------------------------------------------------------------------- 1 | export { backendBaseUrl } from "./backend"; 2 | -------------------------------------------------------------------------------- /shared/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Link, useLocation } from "@remix-run/react"; 3 | 4 | import { CurrentUser } from "../api/currentUser"; 5 | 6 | export function Header() { 7 | const currentUser = useContext(CurrentUser); 8 | const { pathname } = useLocation(); 9 | 10 | return ( 11 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /shared/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { Header } from "./Header" 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "Bundler", 9 | "resolveJsonModule": true, 10 | "target": "ES2022", 11 | "strict": true, 12 | "allowJs": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": ".", 16 | "types": ["@remix-run/node", "vite/client"], 17 | "module": "ESNext", 18 | "paths": { 19 | "~/*": ["./app/*"], 20 | }, 21 | 22 | // Remix takes care of building everything in `remix build`. 23 | "noEmit": true, 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { installGlobals } from "@remix-run/node"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | installGlobals(); 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | remix({ 11 | future: { 12 | v3_fetcherPersist: true, 13 | v3_lazyRouteDiscovery: true, 14 | v3_relativeSplatPath: true, 15 | v3_singleFetch: true, 16 | v3_throwAbortReason: true, 17 | }, 18 | }), 19 | tsconfigPaths(), 20 | ], 21 | }); 22 | --------------------------------------------------------------------------------