├── .env.example ├── .eslintrc.js ├── .github └── workflows │ ├── code-check.yml │ ├── deploy.yml │ ├── format.yml │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── app ├── components │ ├── buttons.ts │ ├── dashboard.tsx │ └── forms.tsx ├── elements │ └── focus-trap.client.ts ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx ├── routes │ ├── _dashboard.items.item.$itemId.tsx │ ├── _dashboard.items.new.tsx │ ├── _dashboard.items.tsx │ ├── _dashboard.tsx │ ├── index.tsx │ ├── login.tsx │ └── logout.ts ├── services.ts └── utils.ts ├── cloudflare.env.d.ts ├── migrations └── 0000_20221117042437_init.sql ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── prisma ├── migrations │ ├── 20221117042437_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.js ├── public └── favicon.ico ├── remix.config.mjs ├── remix.env.d.ts ├── services ├── auth.ts ├── items.d1.ts └── items.mock.ts ├── styles └── global.css ├── tailwind.config.js ├── tests ├── home.spec.ts ├── items.spec.ts └── test-utils.ts ├── tsconfig.json ├── worker.ts └── wrangler.toml /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:../.wrangler/state/d1/APP_DB.sqlite3" 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | ignorePatterns: ["build", "public", "node_modules"], 5 | }; 6 | -------------------------------------------------------------------------------- /.github/workflows/code-check.yml: -------------------------------------------------------------------------------- 1 | name: Code Check 2 | on: 3 | pull_request: {} 4 | push: 5 | branches: [main] 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | jobs: 9 | code-check: 10 | timeout-minutes: 10 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | 18 | - name: Get npm cache directory 19 | id: npm-cache-dir 20 | run: | 21 | echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT 22 | - name: NPM Globals Cache 23 | uses: actions/cache@v3 24 | with: 25 | path: ${{ steps.npm-cache-dir.outputs.dir }} 26 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node- 29 | - name: NPM Locals Cache 30 | uses: actions/cache@v3 31 | with: 32 | path: node_modules 33 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} 34 | restore-keys: | 35 | ${{ runner.os }}-node_modules- 36 | - name: NPM Install 37 | run: npm ci 38 | 39 | - name: Prettier Check 40 | run: npx prettier -c . 41 | 42 | - name: ESLint Check 43 | run: npx eslint . 44 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | workflow_dispatch: 4 | concurrency: 5 | group: ${{ github.workflow }} 6 | jobs: 7 | deploy: 8 | timeout-minutes: 10 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | 16 | - name: Get npm cache directory 17 | id: npm-cache-dir 18 | run: | 19 | echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT 20 | - name: NPM Globals Cache 21 | uses: actions/cache@v3 22 | with: 23 | path: ${{ steps.npm-cache-dir.outputs.dir }} 24 | key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} 25 | restore-keys: | 26 | ${{ runner.os }}-node- 27 | - name: NPM Locals Cache 28 | uses: actions/cache@v3 29 | with: 30 | path: node_modules 31 | key: ${{ runner.os }}-node_modules-${{ hashFiles('package-lock.json') }} 32 | restore-keys: | 33 | ${{ runner.os }}-node_modules- 34 | - name: NPM Install 35 | run: npm ci 36 | 37 | - name: Build 38 | run: npm run build 39 | 40 | - name: Publish 41 | uses: cloudflare/wrangler-action@2.0.0 42 | with: 43 | apiToken: ${{ secrets.CF_API_TOKEN }} 44 | preCommands: | 45 | wrangler d1 migrations apply remix-dashboard-d1-example-db 46 | 47 | - uses: actions/upload-artifact@v3 48 | with: 49 | name: server-build 50 | path: build/ 51 | retention-days: 30 52 | - uses: actions/upload-artifact@v3 53 | with: 54 | name: client-build 55 | path: public/build/ 56 | retention-days: 30 57 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | on: 3 | push: 4 | branches: [main] 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | jobs: 8 | prettier: 9 | timeout-minutes: 1 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | 17 | - name: Get npm cache directory 18 | id: npm-cache-dir 19 | run: | 20 | echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT 21 | - name: NPM Globals Cache 22 | uses: actions/cache@v3 23 | with: 24 | path: ${{ steps.npm-cache-dir.outputs.dir }} 25 | key: ${{ runner.os }}-format-node-globals 26 | 27 | - name: Prettier Check 28 | run: npx prettier -w . 29 | 30 | - uses: stefanzweifel/git-auto-commit-action@v4 31 | with: 32 | commit_message: "ci: prettier" 33 | create_branch: false 34 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: {} 4 | push: 5 | branches: [main] 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | jobs: 9 | integration: 10 | timeout-minutes: 10 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | 18 | - name: Get npm cache directory 19 | id: npm-cache-dir 20 | run: | 21 | echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT 22 | - name: NPM Globals Cache 23 | uses: actions/cache@v3 24 | with: 25 | path: ${{ steps.npm-cache-dir.outputs.dir }} 26 | key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node- 29 | - name: NPM Locals Cache 30 | uses: actions/cache@v3 31 | with: 32 | path: node_modules 33 | key: ${{ runner.os }}-node_modules-${{ hashFiles('package-lock.json') }} 34 | restore-keys: | 35 | ${{ runner.os }}-node_modules- 36 | - name: NPM Install 37 | run: npm ci 38 | 39 | - name: Setup Test DB 40 | run: | 41 | cp ./.env.example ./.env 42 | npx prisma migrate dev 43 | 44 | - name: Playwright Cache 45 | uses: actions/cache@v3 46 | with: 47 | path: ~/.cache/ms-playwright 48 | key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} 49 | - name: Install Playwright Browsers 50 | run: npx playwright install --with-deps 51 | 52 | - name: Run Playwright tests 53 | run: npx playwright test 54 | 55 | - uses: actions/upload-artifact@v3 56 | if: always() 57 | with: 58 | name: playwright-report 59 | path: playwright-report/ 60 | retention-days: 30 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .env 4 | 5 | /.cache/ 6 | /build/ 7 | /public/build/ 8 | /app/styles/ 9 | 10 | /test-results/ 11 | /playwright-report/ 12 | /playwright/.cache/ 13 | 14 | /.wrangler/ 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | app/styles/ 3 | build/ 4 | node_modules/ 5 | public/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-dashboard-template 2 | 3 | A template to get you up and building a dashboard in Remix that runs solely on Cloudflare. 4 | 5 | ## Development 6 | 7 | ### Migrations 8 | 9 | You can apply migrations to your development database by running: 10 | 11 | ```sh 12 | npx prisma migrate dev 13 | ``` 14 | 15 | ### Running the app 16 | 17 | Start the Remix development asset server and Wrangler by running: 18 | 19 | ```sh 20 | npm run dev 21 | ``` 22 | 23 | This starts your app in development mode. 24 | 25 | ## Deployment 26 | 27 | ### First deployment 28 | 29 | Create a database: 30 | 31 | ```sh 32 | wrangler d1 create remix-dashboard-d1-example-db 33 | ``` 34 | 35 | Apply migrations: 36 | 37 | ```sh 38 | wrangler d1 migrations apply remix-dashboard-d1-example-db 39 | ``` 40 | 41 | Build and deploy: 42 | 43 | ```sh 44 | npm run build && npx wrangler publish 45 | ``` 46 | 47 | ## Subsequent deployments 48 | 49 | Configure a GitHub Actions secrets of `CF_API_TOKEN` with the normal worker deployment permissions + the new D1 write permissions to be able to apply migrations on deployment. 50 | 51 | To trigger a deployment: 52 | 53 | - Navigate to the "Actions" tab of your GitHub repository 54 | - Select the "Deploy" action 55 | - Use the dropdown labeled "Run Workflow" to select a branch and start the deployment 56 | 57 | ## Resources 58 | 59 | - [Remix Docs](https://remix.run/docs) 60 | - [CF D1](https://developers.cloudflare.com/d1/) 61 | - [Prisma Docs](https://www.prisma.io/docs/) (only used for generating migrations) 62 | -------------------------------------------------------------------------------- /app/components/buttons.ts: -------------------------------------------------------------------------------- 1 | import cn from "clsx"; 2 | 3 | export function buttonStyles( 4 | { block, full, uniform }: { block?: false; full?: true; uniform?: true } = {}, 5 | className = "" 6 | ) { 7 | return cn( 8 | "px-2 hover:outline", 9 | block === false ? "inline-block" : "block", 10 | full && "w-full text-center", 11 | uniform ? "py-2" : "py-1", 12 | className 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/components/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useRef, 4 | type HTMLAttributes, 5 | type MouseEvent, 6 | type ReactNode, 7 | } from "react"; 8 | import { 9 | Link, 10 | NavLink, 11 | useHref, 12 | useLocation, 13 | useMatches, 14 | useNavigate, 15 | useSearchParams, 16 | useTransition, 17 | type NavLinkProps, 18 | } from "@remix-run/react"; 19 | import cn from "clsx"; 20 | import { buttonStyles } from "./buttons"; 21 | 22 | const FOCUSABLE_ELEMENTS_SELECTOR = `button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])`; 23 | 24 | type BaseHeaderAction = { 25 | label: string; 26 | icon: string; 27 | }; 28 | 29 | type LinkHeaderAction = BaseHeaderAction & { 30 | to: string; 31 | }; 32 | 33 | type ButtonHeaderAction = BaseHeaderAction & HTMLAttributes; 34 | 35 | type HeaderAction = LinkHeaderAction | ButtonHeaderAction; 36 | 37 | export function Dashboard({ children }: { children: ReactNode }) { 38 | return ( 39 |
43 | {children} 44 |
45 | ); 46 | } 47 | 48 | export function DashboardMenu({ 49 | children, 50 | id, 51 | menu, 52 | }: { 53 | children: ReactNode; 54 | id: string; 55 | menu: string; 56 | }) { 57 | const [searchParams] = useSearchParams(); 58 | const matches = useMatches(); 59 | 60 | const menuOpen = searchParams.get("open") == menu; 61 | const hiddenSooner = matches.length > 3; 62 | 63 | return ( 64 |
78 | 82 | {children} 83 | 84 | 90 | 91 |
92 | ); 93 | } 94 | 95 | export function DashboardMenuHeader({ 96 | label, 97 | menu, 98 | }: { 99 | label: string; 100 | menu: string; 101 | }) { 102 | const location = useLocation(); 103 | const [searchParams] = useSearchParams(); 104 | 105 | const menuOpen = searchParams.get("open") == menu; 106 | 107 | const closeMenuSearchParams = new URLSearchParams(searchParams); 108 | closeMenuSearchParams.delete("open"); 109 | const closeMenuTo = useHref({ 110 | ...location, 111 | search: closeMenuSearchParams.toString(), 112 | }); 113 | 114 | const matches = useMatches(); 115 | const hiddenSooner = matches.length > 3; 116 | 117 | return ( 118 |
122 | 130 | 131 | Close Menu 132 | 133 | 134 |

{label}

135 | 136 | 145 |
146 | ); 147 | } 148 | 149 | export function ListSection({ 150 | children, 151 | id, 152 | }: { 153 | children: ReactNode; 154 | id: string; 155 | }) { 156 | const matches = useMatches(); 157 | const hiddenSooner = matches.length > 3; 158 | 159 | return ( 160 |
168 | {children} 169 | 170 | 176 |
177 | ); 178 | } 179 | 180 | export function ListHeader({ 181 | label, 182 | menu, 183 | actions, 184 | }: { 185 | label: string; 186 | menu: string; 187 | actions?: HeaderAction[]; 188 | }) { 189 | const location = useLocation(); 190 | const [searchParams] = useSearchParams(); 191 | 192 | const openMenuSearchParams = new URLSearchParams(searchParams); 193 | openMenuSearchParams.set("open", menu); 194 | const openMenuTo = useHref({ 195 | ...location, 196 | search: openMenuSearchParams.toString(), 197 | }); 198 | 199 | const matches = useMatches(); 200 | const hasDetailsSection = matches.length > 3; 201 | 202 | return ( 203 |
204 | 212 | ➡️ 213 | Open Menu 214 | 215 | 216 |

{label}

217 | {actions && ( 218 | 219 | {actions.map(({ label, icon, ...rest }) => 220 | "to" in rest ? ( 221 | 228 | {icon} 229 | 230 | ) : ( 231 | 241 | ) 242 | )} 243 | 244 | )} 245 | 246 | {hasDetailsSection && ( 247 | 253 | )} 254 |
255 | ); 256 | } 257 | 258 | export function ListItems({ children }: { children: ReactNode }) { 259 | return ( 260 |
261 | 262 |
263 | ); 264 | } 265 | 266 | export function ListItem({ children, className, ...rest }: NavLinkProps) { 267 | return ( 268 |
  • 269 | 272 | cn( 273 | "block p-2 hover:outline", 274 | args.isActive && "underline", 275 | typeof className == "function" ? className(args) : className 276 | ) 277 | } 278 | > 279 | {children} 280 | 281 |
  • 282 | ); 283 | } 284 | 285 | export function DetailsSection({ 286 | children, 287 | id, 288 | }: { 289 | children: ReactNode; 290 | id: string; 291 | }) { 292 | return ( 293 |
    294 | {children} 295 | 296 | 302 |
    303 | ); 304 | } 305 | 306 | export function DetailsHeader({ 307 | label, 308 | actions, 309 | }: { 310 | label: string; 311 | actions?: HeaderAction[]; 312 | }) { 313 | return ( 314 |
    315 | 320 | 321 | Close Menu 322 | 323 | 324 |

    {label}

    325 | 326 | {actions && ( 327 | 328 | {actions.map(({ label, icon, ...rest }) => 329 | "to" in rest ? ( 330 | 337 | {icon} 338 | 339 | ) : ( 340 | 350 | ) 351 | )} 352 | 353 | )} 354 |
    355 | ); 356 | } 357 | 358 | export function ConfirmationDialog({ 359 | id, 360 | title, 361 | confirmLabel, 362 | confirmForm, 363 | denyLabel, 364 | }: { 365 | id: string; 366 | title: string; 367 | confirmLabel: string; 368 | confirmForm: string; 369 | denyLabel: string; 370 | }) { 371 | const navigate = useNavigate(); 372 | 373 | useEffect(() => { 374 | const handleKeyDown = (event: KeyboardEvent) => { 375 | if (event.key == "Escape") { 376 | navigate("."); 377 | } 378 | }; 379 | window.addEventListener("keydown", handleKeyDown); 380 | return () => window.removeEventListener("keydown", handleKeyDown); 381 | }, [navigate]); 382 | 383 | return ( 384 | 389 |
    390 |
    391 | 392 |
    393 |

    {title}

    394 |
    395 |
    396 | 403 | 404 | {denyLabel} 405 | 406 |
    407 |
    408 |
    409 |
    410 |
    411 | ); 412 | } 413 | 414 | export function focusSection(id: string) { 415 | const nextSection = document.getElementById(id); 416 | if (!nextSection) return; 417 | const potentialToFocus = nextSection.querySelectorAll( 418 | FOCUSABLE_ELEMENTS_SELECTOR 419 | ); 420 | 421 | for (const toFocus of potentialToFocus) { 422 | const element = toFocus as HTMLElement; 423 | if ( 424 | toFocus && 425 | typeof element.focus == "function" && 426 | window.getComputedStyle(element).getPropertyValue("display") != "none" 427 | ) { 428 | setTimeout(() => { 429 | element.focus(); 430 | }, 1); 431 | return; 432 | } 433 | } 434 | setTimeout(() => { 435 | if (nextSection) { 436 | nextSection.tabIndex = 0; 437 | nextSection.focus(); 438 | setTimeout(() => { 439 | if (nextSection) { 440 | nextSection.tabIndex = -1; 441 | } 442 | }); 443 | } 444 | }, 1); 445 | } 446 | 447 | export function useAutoFocusSection(regex: RegExp, sectionId: string) { 448 | const location = useLocation(); 449 | const transition = useTransition(); 450 | const [searchParams] = useSearchParams(); 451 | const lastPathnameRef = useRef(); 452 | 453 | const isIdle = transition.state === "idle"; 454 | const somethingOpen = searchParams.has("open"); 455 | useEffect(() => { 456 | let lastPathname = lastPathnameRef.current; 457 | lastPathnameRef.current = location.pathname; 458 | if ( 459 | lastPathname == location.pathname || 460 | somethingOpen || 461 | !isIdle || 462 | !regex.exec(location.pathname) 463 | ) 464 | return; 465 | 466 | const href = document.activeElement?.getAttribute("href"); 467 | if ( 468 | // Nothing is focused yet 469 | document.activeElement === document.body || 470 | // The focused element is a link to this location and 471 | // we're not already focused in the section 472 | (href && 473 | regex.exec(href) && 474 | !document.getElementById(sectionId)!.contains(document.activeElement)) 475 | ) { 476 | focusSection(sectionId); 477 | } 478 | // eslint-disable-next-line react-hooks/exhaustive-deps 479 | }, [isIdle, location.pathname, somethingOpen]); 480 | } 481 | 482 | function jumpToNextSection(event: MouseEvent) { 483 | let currentSection; 484 | let target: HTMLElement | null = event.currentTarget; 485 | while (target) { 486 | if (target.hasAttribute("data-section")) { 487 | currentSection = target; 488 | break; 489 | } 490 | target = target.parentElement; 491 | } 492 | 493 | if (!currentSection) return; 494 | 495 | const sections = document.querySelectorAll("[data-section]"); 496 | let nextSection: HTMLElement | null = null; 497 | for (let i = 0; i < sections.length; i++) { 498 | const section = sections[i]; 499 | if (section === currentSection) { 500 | nextSection = sections[i + 1] as HTMLElement; 501 | break; 502 | } 503 | } 504 | if (nextSection) { 505 | const potentialToFocus = nextSection.querySelectorAll( 506 | FOCUSABLE_ELEMENTS_SELECTOR 507 | ); 508 | 509 | for (const toFocus of potentialToFocus) { 510 | const element = toFocus as HTMLElement; 511 | if ( 512 | toFocus && 513 | typeof element.focus == "function" && 514 | window.getComputedStyle(element).getPropertyValue("display") != "none" 515 | ) { 516 | setTimeout(() => { 517 | element.focus(); 518 | }, 1); 519 | return; 520 | } 521 | } 522 | setTimeout(() => { 523 | if (nextSection) { 524 | nextSection.tabIndex = 0; 525 | nextSection.focus(); 526 | setTimeout(() => { 527 | if (nextSection) { 528 | nextSection.tabIndex = -1; 529 | } 530 | }); 531 | } 532 | }, 1); 533 | } 534 | } 535 | 536 | function jumpToTopOfSection(event: MouseEvent) { 537 | let currentSection: HTMLElement | null = null; 538 | let target: HTMLElement | null = event.currentTarget; 539 | while (target) { 540 | if (target.hasAttribute("data-section")) { 541 | currentSection = target; 542 | break; 543 | } 544 | target = target.parentElement; 545 | } 546 | 547 | if (!currentSection) return; 548 | 549 | const potentialToFocus = currentSection.querySelectorAll( 550 | FOCUSABLE_ELEMENTS_SELECTOR 551 | ); 552 | 553 | for (const toFocus of potentialToFocus) { 554 | const element = toFocus as HTMLElement; 555 | if ( 556 | toFocus && 557 | toFocus !== event.currentTarget && 558 | typeof element.focus == "function" && 559 | window.getComputedStyle(element).getPropertyValue("display") != "none" 560 | ) { 561 | setTimeout(() => { 562 | element.focus(); 563 | }, 1); 564 | return; 565 | } 566 | } 567 | setTimeout(() => { 568 | if (currentSection) { 569 | currentSection.tabIndex = 0; 570 | currentSection.focus(); 571 | setTimeout(() => { 572 | if (currentSection) { 573 | currentSection.tabIndex = -1; 574 | } 575 | }); 576 | } 577 | }, 1); 578 | } 579 | -------------------------------------------------------------------------------- /app/components/forms.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | forwardRef, 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useMemo, 8 | useRef, 9 | type ForwardedRef, 10 | type HTMLProps, 11 | type ReactNode, 12 | } from "react"; 13 | import { json } from "@remix-run/server-runtime"; 14 | import { 15 | Form, 16 | type FetcherWithComponents, 17 | type FormProps, 18 | } from "@remix-run/react"; 19 | import { type ZodError, type ZodFormattedError } from "zod"; 20 | 21 | export function createErrorResponse( 22 | error: ZodError, 23 | restorableFields: string[], 24 | formData: FormData, 25 | status: number = 400 26 | ) { 27 | const restorableFieldSet = new Set(restorableFields); 28 | 29 | const restorable: [string, string][] = []; 30 | for (const [field, value] of formData.entries()) { 31 | if (restorableFieldSet.has(field) && typeof value == "string") { 32 | restorable.push([field, value]); 33 | } 34 | } 35 | 36 | return json( 37 | { 38 | errors: error.format(), 39 | restorable, 40 | }, 41 | status 42 | ); 43 | } 44 | 45 | function createStorageKey(form: HTMLFormElement) { 46 | return `draft:${form.id || form.action}:${form.method}`; 47 | } 48 | 49 | export function discardDraft(form: HTMLFormElement | null | undefined) { 50 | if (!form) return; 51 | 52 | const storageKey = createStorageKey(form); 53 | localStorage.removeItem(storageKey); 54 | } 55 | 56 | const FormContext = createContext<{ 57 | errors?: ZodFormattedError; 58 | restoredFormData?: URLSearchParams; 59 | }>({}); 60 | 61 | function DraftFormImp( 62 | { 63 | onChange: _onChange, 64 | errors, 65 | fetcher, 66 | restorable, 67 | children, 68 | ...rest 69 | }: FormProps & { 70 | fetcher?: FetcherWithComponents; 71 | errors?: ZodFormattedError; 72 | restorable?: [string, string][]; 73 | }, 74 | forwardedRef: ForwardedRef 75 | ) { 76 | const restoredFormData = useMemo(() => { 77 | const formData = new URLSearchParams(); 78 | if (!restorable) return; 79 | for (const [field, value] of restorable) { 80 | formData.append(field, value); 81 | } 82 | return formData; 83 | }, [restorable]); 84 | 85 | const ref = useRef(null); 86 | const refCallback = useCallback( 87 | (form: HTMLFormElement | null) => { 88 | switch (typeof forwardedRef) { 89 | case "function": 90 | forwardedRef(form); 91 | break; 92 | case "object": 93 | if (forwardedRef) { 94 | forwardedRef.current = form; 95 | } 96 | break; 97 | } 98 | 99 | ref.current = form; 100 | }, 101 | [forwardedRef] 102 | ); 103 | 104 | const timeoutRef = useRef(null); 105 | const onChange = useCallback>( 106 | (event) => { 107 | if (_onChange) { 108 | _onChange(event); 109 | } 110 | if (event.defaultPrevented) { 111 | return; 112 | } 113 | 114 | if (timeoutRef.current !== null) { 115 | clearTimeout(timeoutRef.current); 116 | timeoutRef.current = null; 117 | } 118 | 119 | timeoutRef.current = setTimeout(() => { 120 | const form = ref.current; 121 | if (!form) return; 122 | 123 | const formData = new FormData(form); 124 | 125 | const toSave = Array.from(formData.entries()); 126 | const storageKey = createStorageKey(form); 127 | 128 | // TODO: Try catch this and surface error message to user 129 | localStorage.setItem(storageKey, JSON.stringify(toSave)); 130 | }, 200); 131 | }, 132 | [ref, _onChange] 133 | ); 134 | 135 | useEffect(() => { 136 | const form = ref.current; 137 | if (!form) return; 138 | 139 | const storageKey = createStorageKey(form); 140 | const draft = localStorage.getItem(storageKey); 141 | if (draft) { 142 | const entries = JSON.parse(draft); 143 | for (const [name, value] of entries) { 144 | const input = form.elements.namedItem(name); 145 | if (input instanceof HTMLInputElement) { 146 | if (input.type == "checkbox") { 147 | input.checked = value == "on"; 148 | } else if (input.type == "radio") { 149 | if (input.value == value) { 150 | input.checked = true; 151 | } 152 | } else { 153 | input.value = value; 154 | } 155 | } else if (input instanceof HTMLTextAreaElement) { 156 | input.value = value; 157 | } else if (input instanceof HTMLSelectElement) { 158 | input.value = value; 159 | } 160 | } 161 | } 162 | }, [ref]); 163 | 164 | const FormComp = fetcher ? fetcher.Form : Form; 165 | 166 | return ( 167 | , 170 | restoredFormData, 171 | }} 172 | > 173 | 174 | {children} 175 | 176 | 177 | ); 178 | } 179 | 180 | export const DraftForm = forwardRef(DraftFormImp); 181 | 182 | function TextInputImp( 183 | { 184 | id, 185 | name, 186 | defaultValue, 187 | children, 188 | ...rest 189 | }: Omit, "type"> & { children: ReactNode }, 190 | forwardedRef: ForwardedRef 191 | ) { 192 | const { errors, restoredFormData } = useContext(FormContext); 193 | const error = 194 | name && errors 195 | ? (errors as ZodFormattedError<{ [key: typeof name]: string }>)[name] 196 | : undefined; 197 | const restoredValue = 198 | restoredFormData && name ? restoredFormData.get(name) : undefined; 199 | const ariaLabeledBy = id && error ? `${id}-label` : undefined; 200 | 201 | return ( 202 | 225 | ); 226 | } 227 | 228 | export const TextInput = forwardRef(TextInputImp); 229 | -------------------------------------------------------------------------------- /app/elements/focus-trap.client.ts: -------------------------------------------------------------------------------- 1 | import { type DOMAttributes } from "react"; 2 | 3 | const FOCUSABLE_ELEMENTS_SELECTOR = `button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])`; 4 | 5 | class FocusTrap extends HTMLElement { 6 | static get observedAttributes() { 7 | return ["trapped"]; 8 | } 9 | 10 | private _returnTo: HTMLElement | null = null; 11 | 12 | constructor() { 13 | super(); 14 | 15 | this._getFocusableElements = this._getFocusableElements.bind(this); 16 | this._onKeyDown = this._onKeyDown.bind(this); 17 | } 18 | 19 | attributeChangedCallback(name: string, oldValue: string, newValue: string) { 20 | if (name == "trapped") { 21 | if (newValue) { 22 | this._returnTo = document.activeElement as HTMLElement; 23 | setTimeout(() => { 24 | this._getFocusableElements().firstFocusableElement.focus(); 25 | }, 1); 26 | } else if (this._returnTo) { 27 | setTimeout(() => { 28 | (this._returnTo as HTMLElement).focus(); 29 | }, 1); 30 | } 31 | return; 32 | } 33 | } 34 | 35 | connectedCallback() { 36 | if (!this.isConnected) return; 37 | 38 | this.addEventListener("keydown", this._onKeyDown); 39 | } 40 | 41 | disconnectedCallback() { 42 | this.removeEventListener("keydown", this._onKeyDown); 43 | if (this._returnTo) { 44 | setTimeout(() => { 45 | (this._returnTo as HTMLElement).focus(); 46 | }, 1); 47 | } 48 | } 49 | 50 | _onKeyDown(event: KeyboardEvent) { 51 | if (this.getAttribute("trapped") != "true") return; 52 | 53 | const isTabPressed = event.key === "Tab"; 54 | if (!isTabPressed) return; 55 | 56 | const { firstFocusableElement, lastFocusableElement } = 57 | this._getFocusableElements(); 58 | 59 | if (event.shiftKey) { 60 | if (document.activeElement === firstFocusableElement) { 61 | lastFocusableElement.focus(); 62 | event.preventDefault(); 63 | } 64 | } else { 65 | if (document.activeElement === lastFocusableElement) { 66 | firstFocusableElement.focus(); 67 | event.preventDefault(); 68 | } 69 | } 70 | } 71 | 72 | _getFocusableElements() { 73 | const focusableElements = this.querySelectorAll( 74 | FOCUSABLE_ELEMENTS_SELECTOR 75 | ); 76 | const firstFocusableElement = focusableElements[0] as HTMLElement; 77 | const lastFocusableElement = focusableElements[ 78 | focusableElements.length - 1 79 | ] as HTMLElement; 80 | 81 | return { 82 | firstFocusableElement, 83 | lastFocusableElement, 84 | }; 85 | } 86 | } 87 | 88 | let registered = false; 89 | 90 | export function registerFocusTrap() { 91 | if (!registered) { 92 | registered = true; 93 | customElements.define("focus-trap", FocusTrap); 94 | } 95 | } 96 | 97 | type CustomElement = Partial< 98 | T & DOMAttributes & { children: any; class: string; ref?: any } 99 | >; 100 | 101 | declare global { 102 | namespace JSX { 103 | interface IntrinsicElements { 104 | "focus-trap": CustomElement; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | import { registerFocusTrap } from "./elements/focus-trap.client"; 6 | 7 | registerFocusTrap(); 8 | 9 | function hydrate() { 10 | startTransition(() => { 11 | hydrateRoot( 12 | document, 13 | 14 | 15 | 16 | ); 17 | }); 18 | } 19 | 20 | if (window.requestIdleCallback) { 21 | window.requestIdleCallback(hydrate); 22 | } else { 23 | // Safari doesn't support requestIdleCallback 24 | // https://caniuse.com/requestidlecallback 25 | window.setTimeout(hydrate, 1); 26 | } 27 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToReadableStream } from "react-dom/server"; 2 | import { type EntryContext } from "@remix-run/cloudflare"; 3 | import { RemixServer } from "@remix-run/react"; 4 | import isbot from "isbot"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | remixContext: EntryContext 11 | ) { 12 | const body = await renderToReadableStream( 13 | , 14 | { 15 | onError: (error) => { 16 | responseStatusCode = 500; 17 | console.error(error); 18 | }, 19 | signal: request.signal, 20 | } 21 | ); 22 | 23 | if (isbot(request.headers.get("User-Agent"))) { 24 | await body.allReady; 25 | } 26 | 27 | const headers = new Headers(responseHeaders); 28 | headers.set("Content-Type", "text/html"); 29 | 30 | return new Response(body, { 31 | status: responseStatusCode, 32 | headers, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from "react"; 2 | import { type LinksFunction, type MetaFunction } from "@remix-run/cloudflare"; 3 | import { 4 | useFetchers, 5 | useTransition, 6 | Links, 7 | LiveReload, 8 | Meta, 9 | Outlet, 10 | Scripts, 11 | ScrollRestoration, 12 | } from "@remix-run/react"; 13 | import NProgress from "nprogress"; 14 | 15 | import tailwindStylesHref from "./styles/global.css"; 16 | 17 | export const links: LinksFunction = () => [ 18 | { rel: "stylesheet", href: tailwindStylesHref }, 19 | ]; 20 | 21 | export const meta: MetaFunction = () => ({ 22 | charset: "utf-8", 23 | title: "New Remix App", 24 | viewport: "width=device-width,initial-scale=1", 25 | }); 26 | 27 | export default function App() { 28 | useNProgress(); 29 | 30 | return ( 31 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | } 48 | 49 | function useNProgress() { 50 | let transition = useTransition(); 51 | 52 | let fetchers = useFetchers(); 53 | let state = useMemo(() => { 54 | let states = [ 55 | transition.state, 56 | ...fetchers.map((fetcher) => fetcher.state), 57 | ]; 58 | if (states.every((state) => state === "idle")) return "idle" as const; 59 | return "busy" as const; 60 | }, [transition.state, fetchers]); 61 | 62 | useEffect(() => { 63 | NProgress.configure({ showSpinner: false }); 64 | }, []); 65 | 66 | useEffect(() => { 67 | switch (state) { 68 | case "busy": 69 | NProgress.start(); 70 | break; 71 | default: 72 | NProgress.done(); 73 | break; 74 | } 75 | }, [state]); 76 | } 77 | -------------------------------------------------------------------------------- /app/routes/_dashboard.items.item.$itemId.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | redirect, 4 | type ActionArgs, 5 | type LoaderArgs, 6 | } from "@remix-run/cloudflare"; 7 | import { 8 | Form, 9 | useLoaderData, 10 | useSearchParams, 11 | type ShouldReloadFunction, 12 | } from "@remix-run/react"; 13 | import { buttonStyles } from "~/components/buttons"; 14 | 15 | import { 16 | useAutoFocusSection, 17 | ConfirmationDialog, 18 | DetailsHeader, 19 | DetailsSection, 20 | } from "~/components/dashboard"; 21 | 22 | export async function loader({ 23 | context: { 24 | services: { auth, items }, 25 | }, 26 | params, 27 | request, 28 | }: LoaderArgs) { 29 | const [item] = await Promise.all([ 30 | items.getItemById(params.itemId!), 31 | auth.requireUser(request), 32 | ]); 33 | 34 | if (!item) { 35 | throw json("Item not found", { status: 404 }); 36 | } 37 | 38 | return json({ item }); 39 | } 40 | 41 | export async function action({ 42 | context: { 43 | services: { auth, items }, 44 | }, 45 | params, 46 | request, 47 | }: ActionArgs) { 48 | const [formData] = await Promise.all([ 49 | request.formData(), 50 | auth.requireUser(request), 51 | ]); 52 | 53 | switch (formData.get("intent")) { 54 | case "delete": 55 | await items.deleteItemById(params.itemId!); 56 | return redirect("/items"); 57 | default: 58 | return json(null, 400); 59 | } 60 | } 61 | 62 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => 63 | !!submission && 64 | ["/login", "/logout", "/items"].some((pathname) => 65 | submission.action.startsWith(pathname) 66 | ); 67 | 68 | export default function Item() { 69 | useAutoFocusSection(/^\/items\/./i, "dashboard-item"); 70 | 71 | const { item } = useLoaderData(); 72 | const [searchParams] = useSearchParams(); 73 | 74 | const confirmDelete = searchParams.has("delete"); 75 | 76 | return ( 77 | 78 | 88 | 89 |
    90 | 97 |
    98 | 99 | {confirmDelete && ( 100 | <> 101 | 108 |
    109 | 110 |
    111 | 112 | )} 113 |
    114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /app/routes/_dashboard.items.new.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { 3 | redirect, 4 | type ActionArgs, 5 | type LoaderArgs, 6 | } from "@remix-run/cloudflare"; 7 | import { useActionData, useLocation, useTransition } from "@remix-run/react"; 8 | import { z } from "zod"; 9 | import { zfd } from "zod-form-data"; 10 | 11 | import { DetailsHeader, DetailsSection } from "~/components/dashboard"; 12 | import { 13 | createErrorResponse, 14 | discardDraft, 15 | DraftForm, 16 | TextInput, 17 | } from "~/components/forms"; 18 | import { buttonStyles } from "~/components/buttons"; 19 | 20 | const restorableFields = ["label"]; 21 | const schema = zfd.formData({ 22 | label: zfd.text( 23 | z 24 | .string() 25 | .transform((s) => s.trim()) 26 | .refine((s) => s.length > 0, "Can't be just whitespace characters") 27 | ), 28 | }); 29 | 30 | export async function loader({ 31 | context: { 32 | services: { auth }, 33 | }, 34 | request, 35 | }: LoaderArgs) { 36 | await auth.requireUser(request); 37 | return null; 38 | } 39 | 40 | export async function action({ 41 | context: { 42 | services: { auth, items }, 43 | }, 44 | request, 45 | }: ActionArgs) { 46 | const [formData] = await Promise.all([ 47 | request.formData(), 48 | auth.requireUser(request), 49 | ]); 50 | const parseResult = schema.safeParse(formData); 51 | 52 | if (!parseResult.success) { 53 | return createErrorResponse(parseResult.error, restorableFields, formData); 54 | } 55 | 56 | const itemId = await items.createItem({ label: parseResult.data.label }); 57 | 58 | return redirect(`/items/item/${itemId}`); 59 | } 60 | 61 | export default function NewItem() { 62 | const { errors, restorable } = useActionData() || {}; 63 | const [formKey, setFormKey] = useState(0); 64 | const formRef = useRef(null); 65 | 66 | const location = useLocation(); 67 | const transition = useTransition(); 68 | useEffect(() => { 69 | const form = formRef.current; 70 | return () => { 71 | if ( 72 | transition.state == "loading" && 73 | transition.type == "actionRedirect" && 74 | transition.submission.action == location.pathname 75 | ) { 76 | discardDraft(form); 77 | } 78 | }; 79 | }, [transition, location]); 80 | 81 | return ( 82 | 83 | { 90 | discardDraft(formRef.current); 91 | setFormKey((key) => key + 1); 92 | event.preventDefault(); 93 | }, 94 | }, 95 | ]} 96 | /> 97 | 98 | 107 | 115 | Label 116 | 117 | 118 | 121 | 122 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /app/routes/_dashboard.items.tsx: -------------------------------------------------------------------------------- 1 | import { json, type LoaderArgs } from "@remix-run/cloudflare"; 2 | import { 3 | Outlet, 4 | useLoaderData, 5 | type ShouldReloadFunction, 6 | } from "@remix-run/react"; 7 | 8 | import { 9 | useAutoFocusSection, 10 | ListHeader, 11 | ListItem, 12 | ListItems, 13 | ListSection, 14 | } from "~/components/dashboard"; 15 | 16 | export async function loader({ 17 | context: { 18 | services: { auth, items }, 19 | }, 20 | request, 21 | }: LoaderArgs) { 22 | const itemsPromise = items.getAllItems(); 23 | 24 | await auth.requireUser(request); 25 | 26 | return json({ 27 | items: await itemsPromise, 28 | }); 29 | } 30 | 31 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => 32 | !!submission && 33 | ["/login", "/logout", "/items"].some((pathname) => 34 | submission.action.startsWith(pathname) 35 | ); 36 | 37 | export default function Items() { 38 | useAutoFocusSection(/^\/items\/?$/i, "dashboard-items"); 39 | 40 | const { items } = useLoaderData(); 41 | 42 | return ( 43 | <> 44 | 45 | 56 | 57 | 58 | {items.map(({ id, label }) => ( 59 | 60 | {label} 61 | 62 | ))} 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /app/routes/_dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { type LoaderArgs } from "@remix-run/cloudflare"; 2 | import { 3 | Form, 4 | Outlet, 5 | useLocation, 6 | type ShouldReloadFunction, 7 | } from "@remix-run/react"; 8 | import { buttonStyles } from "~/components/buttons"; 9 | 10 | import { 11 | Dashboard, 12 | DashboardMenu, 13 | DashboardMenuHeader, 14 | ListItem, 15 | ListItems, 16 | } from "~/components/dashboard"; 17 | 18 | export async function loader({ 19 | context: { 20 | services: { auth }, 21 | }, 22 | request, 23 | }: LoaderArgs) { 24 | await auth.requireUser(request); 25 | 26 | return null; 27 | } 28 | 29 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => 30 | !!submission && 31 | ["/login", "/logout"].some((pathname) => 32 | submission.action.startsWith(pathname) 33 | ); 34 | 35 | export default function DashboardLayout() { 36 | const location = useLocation(); 37 | 38 | const redirectTo = encodeURIComponent(location.pathname + location.search); 39 | 40 | return ( 41 | <> 42 | 43 | 44 | 45 | 46 | 47 | Items 48 | 49 | 50 |
    51 | 52 |
    53 |
    54 | 62 |
    63 |
    64 |
    65 | 66 | 67 |
    68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { json, type LoaderArgs } from "@remix-run/cloudflare"; 2 | import { Form, Link, useLoaderData } from "@remix-run/react"; 3 | import { buttonStyles } from "~/components/buttons"; 4 | 5 | export async function loader({ 6 | context: { 7 | services: { auth }, 8 | }, 9 | request, 10 | }: LoaderArgs) { 11 | const user = await auth.getUser(request); 12 | 13 | return json({ loggedIn: !!user }); 14 | } 15 | 16 | export default function Home() { 17 | const { loggedIn } = useLoaderData(); 18 | return ( 19 |
    20 |

    Remix Dashboard Starter

    21 | 22 |

    A simple dashboard starter to get you up and running.

    23 | 24 | {loggedIn ? ( 25 | 26 | Dashboard 27 | 28 | ) : ( 29 |
    30 | 31 |
    32 | )} 33 |
    34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { type LoaderArgs } from "@remix-run/cloudflare"; 2 | import { Form, Link, useSearchParams } from "@remix-run/react"; 3 | 4 | import * as utils from "~/utils"; 5 | 6 | // Simulate a login 7 | export async function action({ 8 | context: { 9 | services: { auth }, 10 | }, 11 | request, 12 | }: LoaderArgs) { 13 | const url = new URL(request.url); 14 | const redirectTo = utils.getRedirectTo(url.searchParams, "/"); 15 | 16 | await auth.authenticator.authenticate("mock", request, { 17 | successRedirect: redirectTo, 18 | }); 19 | 20 | return null; 21 | } 22 | 23 | export default function Login() { 24 | const [searchParams] = useSearchParams(); 25 | const redirectTo = searchParams.get("redirectTo") || "/items"; 26 | 27 | return ( 28 |
    29 |
    34 | 37 |
    38 | 39 | Go back home 40 | 41 |
    42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/routes/logout.ts: -------------------------------------------------------------------------------- 1 | import { redirect, type LoaderArgs } from "@remix-run/cloudflare"; 2 | 3 | import * as utils from "~/utils"; 4 | 5 | export async function loader({ 6 | context: { 7 | services: { auth }, 8 | }, 9 | request, 10 | }: LoaderArgs) { 11 | const url = new URL(request.url); 12 | const redirectTo = utils.getRedirectTo(url.searchParams, "/"); 13 | 14 | return redirect(redirectTo, { 15 | headers: { 16 | "Set-Cookie": await auth.destroySession(request), 17 | "X-Remix-Revalidate": "1", 18 | }, 19 | }); 20 | } 21 | 22 | export async function action({ 23 | context: { 24 | services: { auth }, 25 | }, 26 | request, 27 | }: LoaderArgs) { 28 | const url = new URL(request.url); 29 | const redirectTo = utils.getRedirectTo(url.searchParams, "/"); 30 | 31 | return redirect(redirectTo, { 32 | headers: { 33 | "Set-Cookie": await auth.destroySession(request), 34 | }, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /app/services.ts: -------------------------------------------------------------------------------- 1 | import { type Authenticator } from "remix-auth"; 2 | 3 | export interface Item { 4 | id: string; 5 | label: string; 6 | } 7 | 8 | export interface ItemsService { 9 | getAllItems(): Promise; 10 | getItemById(id: string): Promise; 11 | createItem({ label }: { label: string }): Promise; 12 | deleteItemById(id: string): Promise; 13 | } 14 | 15 | export interface User { 16 | id: string; 17 | } 18 | 19 | export interface AuthService { 20 | authenticator: Authenticator; 21 | getUser(request: Request): Promise; 22 | requireUser(request: Request): Promise; 23 | // setUser(request: Request, user: User): Promise; 24 | destroySession(request: Request): Promise; 25 | } 26 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | export function getRedirectTo(searchParams: URLSearchParams, fallback = "/") { 2 | let redirect = searchParams.get("redirectTo") || fallback; 3 | redirect = redirect.trim(); 4 | if (redirect.startsWith("//") || redirect.startsWith("http")) { 5 | redirect = fallback; 6 | } 7 | return redirect || fallback; 8 | } 9 | -------------------------------------------------------------------------------- /cloudflare.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "__STATIC_CONTENT_MANIFEST" { 4 | const value: string; 5 | export default value; 6 | } 7 | 8 | interface Env { 9 | __STATIC_CONTENT: string; 10 | 11 | APP_DB: D1Database; 12 | SESSION_SECRET?: string; 13 | } 14 | -------------------------------------------------------------------------------- /migrations/0000_20221117042437_init.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0000 2022-11-17T04:37:22.076Z 2 | 3 | -- CreateTable 4 | CREATE TABLE "Item" ( 5 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 6 | "label" TEXT NOT NULL 7 | ); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-dashboard-template", 3 | "private": true, 4 | "sideEffects": false, 5 | "scripts": { 6 | "build": "npm run build:css && remix build", 7 | "build:css": "postcss ./styles/global.css -d ./app/styles", 8 | "dev": "npm run build && concurrently \"npm:dev:*\"", 9 | "dev:css": "npm run build:css -- --watch", 10 | "dev:wrangler": "cross-env NODE_ENV=development wrangler dev --env development --local --persist", 11 | "dev:remix": "remix watch", 12 | "format": "prettier -w .", 13 | "lint": "eslint .", 14 | "start": "node --loader tsx ./server.ts", 15 | "test": "playwright test --project=chromium" 16 | }, 17 | "dependencies": { 18 | "@cloudflare/kv-asset-handler": "^0.2.0", 19 | "@prisma/client": "^4.6.1", 20 | "@remix-run/cloudflare": "^1.7.5", 21 | "@remix-run/react": "^1.7.5", 22 | "clsx": "^1.2.1", 23 | "compression": "^1.7.4", 24 | "express": "^4.18.2", 25 | "isbot": "^3.6.5", 26 | "nprogress": "^0.2.0", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "remix-auth": "^3.3.0", 30 | "remix-auth-form": "^1.2.0", 31 | "tsx": "^3.12.1", 32 | "zod": "^3.19.1", 33 | "zod-form-data": "^1.2.4" 34 | }, 35 | "devDependencies": { 36 | "@cloudflare/workers-types": "^3.18.0", 37 | "@playwright/test": "^1.27.1", 38 | "@remix-run/dev": "^1.7.5", 39 | "@remix-run/eslint-config": "^1.7.5", 40 | "@types/compression": "^1.7.2", 41 | "@types/express": "^4.17.14", 42 | "@types/nprogress": "^0.2.0", 43 | "@types/react": "^18.0.25", 44 | "@types/react-dom": "^18.0.8", 45 | "autoprefixer": "^10.4.13", 46 | "concurrently": "^7.5.0", 47 | "cross-env": "^7.0.3", 48 | "dotenv": "^16.0.3", 49 | "eslint": "^8.27.0", 50 | "nodemon": "^2.0.20", 51 | "postcss": "^8.4.19", 52 | "postcss-cli": "^10.0.0", 53 | "postcss-import": "^15.0.0", 54 | "prettier": "^2.7.1", 55 | "prisma": "^4.6.1", 56 | "remix-flat-routes": "^0.4.8", 57 | "tailwindcss": "^3.2.4", 58 | "typescript": "^4.8.4", 59 | "wrangler": "^2.3.2" 60 | }, 61 | "engines": { 62 | "node": ">=14" 63 | }, 64 | "prisma": { 65 | "seed": "node ./prisma/seed.js" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | import { devices } from "@playwright/test"; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: "./tests", 15 | /* Maximum time one test can run for. */ 16 | timeout: (process.env.CI ? 30 : 5) * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000, 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: !!process.env.CI, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 1, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: process.env.CI 34 | ? [["github"], ["html", { open: "never" }]] 35 | : [["html", { open: "on-failure" }]], 36 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 37 | use: { 38 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 39 | actionTimeout: 0, 40 | /* Base URL to use in actions like `await page.goto('/')`. */ 41 | baseURL: "http://localhost:8787", 42 | 43 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 44 | trace: "on-first-retry", 45 | }, 46 | 47 | /* Configure projects for major browsers */ 48 | projects: [ 49 | { 50 | name: "chromium", 51 | use: { 52 | ...devices["Desktop Chrome"], 53 | }, 54 | }, 55 | 56 | { 57 | name: "firefox", 58 | use: { 59 | ...devices["Desktop Firefox"], 60 | }, 61 | }, 62 | 63 | { 64 | name: "webkit", 65 | use: { 66 | ...devices["Desktop Safari"], 67 | }, 68 | }, 69 | ], 70 | // [ 71 | // { 72 | // name: "chromium", 73 | // use: { 74 | // ...devices["Desktop Chrome"], 75 | // }, 76 | // }, 77 | 78 | // { 79 | // name: "firefox", 80 | // use: { 81 | // ...devices["Desktop Firefox"], 82 | // }, 83 | // }, 84 | 85 | // { 86 | // name: "webkit", 87 | // use: { 88 | // ...devices["Desktop Safari"], 89 | // }, 90 | // }, 91 | 92 | // /* Test against mobile viewports. */ 93 | // // { 94 | // // name: 'Mobile Chrome', 95 | // // use: { 96 | // // ...devices['Pixel 5'], 97 | // // }, 98 | // // }, 99 | // // { 100 | // // name: 'Mobile Safari', 101 | // // use: { 102 | // // ...devices['iPhone 12'], 103 | // // }, 104 | // // }, 105 | 106 | // /* Test against branded browsers. */ 107 | // // { 108 | // // name: 'Microsoft Edge', 109 | // // use: { 110 | // // channel: 'msedge', 111 | // // }, 112 | // // }, 113 | // // { 114 | // // name: 'Google Chrome', 115 | // // use: { 116 | // // channel: 'chrome', 117 | // // }, 118 | // // }, 119 | // ], 120 | 121 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 122 | outputDir: "test-results/", 123 | 124 | /* Run your local dev server before starting the tests */ 125 | webServer: { 126 | command: "npm run build && npm run dev:wrangler", 127 | port: 8787, 128 | }, 129 | }; 130 | 131 | export default config; 132 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /prisma/migrations/20221117042437_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Item" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "label" TEXT NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /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 Item { 14 | id Int @id @default(autoincrement()) 15 | label String 16 | } 17 | -------------------------------------------------------------------------------- /prisma/seed.js: -------------------------------------------------------------------------------- 1 | const { PrismaClient } = require("@prisma/client"); 2 | 3 | async function seed() { 4 | const client = new PrismaClient(); 5 | const itemsCount = await client.item.count(); 6 | if (!itemsCount) { 7 | await client.item.create({ 8 | data: { 9 | label: "Item 1", 10 | }, 11 | }); 12 | await client.item.create({ 13 | data: { 14 | label: "Item 2", 15 | }, 16 | }); 17 | await client.item.create({ 18 | data: { 19 | label: "Item 3", 20 | }, 21 | }); 22 | } 23 | } 24 | 25 | seed().then( 26 | () => { 27 | process.exit(0); 28 | }, 29 | (reason) => { 30 | console.error(reason); 31 | process.exit(1); 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/remix-dashboard-d1/27c853e66c957a9c0c2329afb345a2452cb5d16b/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.mjs: -------------------------------------------------------------------------------- 1 | import { flatRoutes } from "remix-flat-routes"; 2 | 3 | /** @type {import('@remix-run/dev').AppConfig} */ 4 | export default { 5 | serverModuleFormat: "esm", 6 | devServerBroadcastDelay: 1000, 7 | ignoredRouteFiles: ["**/*"], 8 | routes: async (defineRoutes) => { 9 | return flatRoutes("routes", defineRoutes, { 10 | basePath: "/", 11 | paramPrefixChar: "$", 12 | ignoredRouteFiles: [], 13 | }); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import type { AuthService, ItemsService } from "./app/services"; 5 | 6 | declare module "@remix-run/server-runtime" { 7 | export interface AppLoadContext { 8 | services: { 9 | auth: AuthService; 10 | items: ItemsService; 11 | }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /services/auth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | redirect, 3 | createCookieSessionStorage, 4 | type SessionStorage, 5 | } from "@remix-run/cloudflare"; 6 | import { Authenticator } from "remix-auth"; 7 | import { FormStrategy } from "remix-auth-form"; 8 | 9 | import { type AuthService, type User } from "~/services"; 10 | 11 | export class RemixAuthService implements AuthService { 12 | public authenticator: Authenticator; 13 | private sessionStorage: SessionStorage; 14 | 15 | constructor(secrets: string[]) { 16 | this.sessionStorage = createCookieSessionStorage({ 17 | cookie: { 18 | name: "auth", 19 | httpOnly: true, 20 | path: "/", 21 | sameSite: "lax", 22 | secrets, 23 | }, 24 | }); 25 | 26 | this.authenticator = new Authenticator(this.sessionStorage); 27 | this.authenticator.use( 28 | new FormStrategy(async ({ form, context }) => { 29 | return { id: "1" }; 30 | }), 31 | "mock" 32 | ); 33 | } 34 | 35 | async getUser(request: Request) { 36 | const cookie = request.headers.get("Cookie"); 37 | const session = await this.sessionStorage.getSession(cookie); 38 | const user = await this.authenticator.isAuthenticated(session); 39 | return user || undefined; 40 | } 41 | 42 | async requireUser(request: Request) { 43 | const user = await this.getUser(request); 44 | 45 | if (!user) { 46 | const url = new URL(request.url); 47 | const redirectTo = url.pathname + url.search; 48 | 49 | const searchParams = new URLSearchParams({ 50 | redirectTo, 51 | }); 52 | 53 | throw redirect(`/login?${searchParams.toString()}`); 54 | } 55 | 56 | return user; 57 | } 58 | 59 | async destroySession(request: Request) { 60 | const cookie = request.headers.get("Cookie"); 61 | const session = await this.sessionStorage.getSession(cookie); 62 | 63 | return await this.sessionStorage.destroySession(session, { 64 | secure: request.url.startsWith("https://"), 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /services/items.d1.ts: -------------------------------------------------------------------------------- 1 | import { type Item, type ItemsService } from "~/services"; 2 | 3 | declare global { 4 | var __MOCK_ITEMS__: Item[]; 5 | } 6 | 7 | export class D1ItemsService implements ItemsService { 8 | constructor(private db: D1Database) {} 9 | 10 | async getAllItems() { 11 | const result = await this.db 12 | .prepare("SELECT `id`, `label` FROM `Item`;") 13 | .all<{ 14 | id: number; 15 | label: string; 16 | }>(); 17 | 18 | if (result.error || !result.results) { 19 | throw new Error(result.error || "Failed to query items."); 20 | } 21 | 22 | return result.results.map((item) => ({ 23 | id: "" + item.id, 24 | label: item.label, 25 | })); 26 | } 27 | async getItemById(id: string) { 28 | const result = await this.db 29 | .prepare("SELECT `id`, `label` FROM `Item` WHERE `id` = ?;") 30 | .bind(id) 31 | .first<{ id: number; label: string }>(); 32 | 33 | return result 34 | ? { 35 | id: "" + result.id, 36 | label: result.label, 37 | } 38 | : undefined; 39 | } 40 | async createItem({ label }: { label: string }) { 41 | const result = await this.db 42 | .prepare("INSERT INTO `Item` (`label`) VALUES (?) RETURNING `id`;") 43 | .bind(label) 44 | .first<{ id: number }>(); 45 | const createdId = result?.id; 46 | 47 | if (typeof createdId !== "number") { 48 | throw new Error("Failed to create item."); 49 | } 50 | 51 | return "" + createdId; 52 | } 53 | async deleteItemById(id: string) { 54 | const result = await this.db 55 | .prepare("DELETE FROM `Item` WHERE `id` = ?;") 56 | .bind(id) 57 | .run(); 58 | 59 | // `changes` is not yet implemented in the D1 alpha 60 | // if (!result.changes) { 61 | // throw new Error("Failed to delete item."); 62 | // } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /services/items.mock.ts: -------------------------------------------------------------------------------- 1 | import { type Item, type ItemsService } from "~/services"; 2 | 3 | declare global { 4 | var __MOCK_ITEMS__: Item[]; 5 | } 6 | 7 | export class MockItemsService implements ItemsService { 8 | constructor() { 9 | global.__MOCK_ITEMS__ = global.__MOCK_ITEMS__ || [ 10 | { 11 | id: "1", 12 | label: "Item 1", 13 | }, 14 | { 15 | id: "2", 16 | label: "Item 2", 17 | }, 18 | { 19 | id: "3", 20 | label: "Item 3", 21 | }, 22 | ]; 23 | } 24 | 25 | async getAllItems() { 26 | return global.__MOCK_ITEMS__; 27 | } 28 | async getItemById(id: string) { 29 | return global.__MOCK_ITEMS__.find((item) => item.id == id); 30 | } 31 | async createItem({ label }: { label: string }) { 32 | const id = String(Date.now()); 33 | global.__MOCK_ITEMS__.push({ 34 | id, 35 | label, 36 | }); 37 | return id; 38 | } 39 | async deleteItemById(id: string) { 40 | global.__MOCK_ITEMS__ = global.__MOCK_ITEMS__.filter( 41 | (item) => item.id != id 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | @import "nprogress/nprogress.css"; 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["app/**/*.{ts,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tests/home.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | 3 | test("can login with github from home page", async ({ page }) => { 4 | await page.goto("/"); 5 | const loginButton = await page.getByRole("button", { 6 | name: "Login with GitHub", 7 | }); 8 | await loginButton.click(); 9 | await page.waitForSelector(`a:text("Dashboard")`); 10 | }); 11 | -------------------------------------------------------------------------------- /tests/items.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | 3 | import { authenticatedGoTo } from "./test-utils"; 4 | 5 | test("can create and delete item", async ({ page }) => { 6 | await authenticatedGoTo(page, "/items", "#dashboard-items"); 7 | 8 | const newItemLink = await page.getByRole("link", { name: "New Item" }); 9 | await newItemLink.click(); 10 | 11 | const newItemForm = await page.waitForSelector("#new-item-form"); 12 | 13 | const labelInput = await newItemForm.waitForSelector(`input[name="label"]`); 14 | const itemLabel = `Item ${Date.now()}`; 15 | await labelInput.fill(itemLabel); 16 | 17 | const submitButton = await newItemForm.waitForSelector( 18 | `button[type="submit"]` 19 | ); 20 | await submitButton.click(); 21 | 22 | await page.waitForSelector("#dashboard-item"); 23 | 24 | const deleteLink = await page.getByRole("link", { name: "Delete Item" }); 25 | await deleteLink.click(); 26 | 27 | const deleteDialog = await page.waitForSelector( 28 | "#dashboard-item-delete-dialog" 29 | ); 30 | 31 | const confirmButton = await deleteDialog.waitForSelector( 32 | `button[type="submit"]` 33 | ); 34 | await confirmButton.click(); 35 | 36 | await page.waitForSelector("#dashboard-item", { state: "detached" }); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { type Page } from "@playwright/test"; 2 | 3 | export async function authenticatedGoTo( 4 | page: Page, 5 | url: string, 6 | selector: string 7 | ) { 8 | await page.goto(url, { waitUntil: "networkidle" }); 9 | 10 | const readyPromise = page.waitForSelector(selector); 11 | 12 | const loginForm = await page.$("#login-with-github-form"); 13 | if (loginForm) { 14 | const loginButton = await loginForm.$("button"); 15 | await loginButton!.click(); 16 | } 17 | 18 | await readyPromise; 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["remix.init/_prisma"], 3 | "include": ["cloudflare.env.d.ts", "remix.env.d.ts", "**/*.ts", "**/*.tsx"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "NodeNext", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "target": "ES2019", 13 | "strict": true, 14 | "allowJs": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | 21 | // Remix takes care of building everything in `remix build`. 22 | "noEmit": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /worker.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "@remix-run/cloudflare"; 2 | import { type AppLoadContext } from "@remix-run/server-runtime"; 3 | import { 4 | getAssetFromKV, 5 | NotFoundError, 6 | MethodNotAllowedError, 7 | type CacheControl, 8 | } from "@cloudflare/kv-asset-handler"; 9 | import manifestJSON from "__STATIC_CONTENT_MANIFEST"; 10 | 11 | import { RemixAuthService } from "./services/auth"; 12 | import { D1ItemsService } from "./services/items.d1"; 13 | import { MockItemsService } from "./services/items.mock"; 14 | 15 | import * as build from "./build/index.js"; 16 | 17 | const assetManifest = JSON.parse(manifestJSON); 18 | const remixHandler = createRequestHandler(build, process.env.NODE_ENV); 19 | 20 | export default { 21 | async fetch( 22 | request: Request, 23 | env: Env, 24 | ctx: ExecutionContext 25 | ): Promise { 26 | try { 27 | return await getAssetFromKV( 28 | { 29 | request, 30 | waitUntil(promise) { 31 | return ctx.waitUntil(promise); 32 | }, 33 | }, 34 | { 35 | cacheControl, 36 | ASSET_NAMESPACE: env.__STATIC_CONTENT, 37 | ASSET_MANIFEST: assetManifest, 38 | } 39 | ); 40 | } catch (e) { 41 | if (e instanceof NotFoundError || e instanceof MethodNotAllowedError) { 42 | // fall through to the remix handler 43 | } else { 44 | return new Response("An unexpected error occurred", { status: 500 }); 45 | } 46 | } 47 | 48 | if (!env.SESSION_SECRET) { 49 | console.error("Please define the SESSION_SECRET environment variable"); 50 | return new Response("Internal Server Error", { status: 500 }); 51 | } 52 | 53 | const loadContext: AppLoadContext = { 54 | services: { 55 | auth: new RemixAuthService([env.SESSION_SECRET!]), 56 | // items: new MockItemsService(), 57 | items: new D1ItemsService(env.APP_DB), 58 | }, 59 | }; 60 | 61 | try { 62 | return await remixHandler(request, loadContext); 63 | } catch (reason) { 64 | console.error(reason); 65 | return new Response("Internal Server Error", { status: 500 }); 66 | } 67 | }, 68 | }; 69 | 70 | function cacheControl(request: Request): Partial { 71 | const url = new URL(request.url); 72 | if (url.pathname === "/sw.js") { 73 | return { 74 | browserTTL: 0, 75 | edgeTTL: 0, 76 | }; 77 | } 78 | 79 | if (url.pathname.startsWith("/build")) { 80 | // Cache build files for 1 year since they have a hash in their URL 81 | return { 82 | browserTTL: 60 * 60 * 24 * 365, 83 | edgeTTL: 60 * 60 * 24 * 365, 84 | }; 85 | } 86 | 87 | // Cache everything else for 10 minutes 88 | return { 89 | browserTTL: 60 * 10, 90 | edgeTTL: 60 * 10, 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "remix-dashboard-d1" 2 | main = "worker.ts" 3 | compatibility_date = "2022-11-17" 4 | compatibility_flags = ["streams_enable_constructors"] 5 | 6 | [site] 7 | bucket = "./public" 8 | 9 | [[d1_databases]] 10 | binding = "APP_DB" 11 | database_name = "remix-dashboard-d1-example-db" 12 | database_id = "c7dc8b3e-f90c-40e4-a1a1-13356eda9209" 13 | 14 | [env.development.define] 15 | "process.env.REMIX_DEV_SERVER_WS_PORT" = "8002" 16 | 17 | [env.development.vars] 18 | SESSION_SECRET = "this-should-be-a-secret" 19 | 20 | [[env.development.d1_databases]] 21 | binding = "APP_DB" 22 | database_name = "remix-dashboard-d1-example-db" 23 | database_id = "" 24 | --------------------------------------------------------------------------------