├── cypress.json ├── remix.init ├── .gitignore ├── gitignore ├── package.json ├── index.js └── yarn.lock ├── .storybook ├── preview-head.html ├── preview.js └── main.js ├── public ├── favicon.ico └── img │ └── eurodance-bg-no-text.jpg ├── cypress ├── support │ ├── index.ts │ ├── e2e.ts │ ├── delete-user.ts │ ├── create-user.ts │ └── commands.ts ├── .eslintrc.js ├── fixtures │ └── example.json ├── plugins │ └── index.ts ├── tsconfig.json └── e2e │ └── smoke.cy.ts ├── .dockerignore ├── .env.example ├── .prettierignore ├── remix.env.d.ts ├── vercel.json ├── mocks ├── index.js ├── start.ts └── README.md ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20220307190657_init │ │ └── migration.sql ├── schema.prisma └── seed.ts ├── tailwind.config.js ├── .gitpod.Dockerfile ├── app ├── routes │ ├── distant-api.tsx │ ├── logout.tsx │ ├── notes │ │ ├── index.tsx │ │ ├── $noteId.tsx │ │ └── new.tsx │ ├── healthcheck.tsx │ ├── notes.tsx │ ├── distant-api │ │ ├── mutation.tsx │ │ ├── query.tsx │ │ └── admin-area.tsx │ ├── join.tsx │ ├── login.tsx │ └── index.tsx ├── utils.test.ts ├── entry.client.tsx ├── components │ ├── tests │ │ ├── GithubButtons.stories.tsx │ │ └── GithubButtons.test.tsx │ └── GithubButtons.tsx ├── db.server.ts ├── models │ ├── note.server.ts │ └── user.server.ts ├── root.tsx ├── entry.server.tsx ├── utils.ts └── session.server.ts ├── .vulcan └── scripts │ └── is-monorepo.js ├── start.sh ├── vercel.server.js ├── test └── setup-test-env.ts ├── vitest.config.ts ├── .eslintrc.js ├── .gitignore ├── stories ├── header.css ├── Header.stories.tsx ├── button.css ├── Page.stories.tsx ├── Button.tsx ├── assets │ ├── direction.svg │ ├── flow.svg │ ├── code-brackets.svg │ ├── comments.svg │ ├── repo.svg │ ├── plugin.svg │ ├── stackalt.svg │ └── colors.svg ├── Button.stories.tsx ├── page.css ├── Header.tsx ├── Page.tsx └── Introduction.stories.mdx ├── tsconfig.json ├── cypress.config.ts ├── remix.config.js ├── fly.toml ├── .gitpod.yml ├── Dockerfile ├── .codesandbox └── tasks.json ├── package.json ├── .github └── workflows │ └── deploy.yml └── README.md /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /remix.init/.gitignore: -------------------------------------------------------------------------------- 1 | .yarn/* -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VulcanJS/eurodance-stack/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/cypress/add-commands"; 2 | import "./commands"; 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | .DS_Store 4 | .env 5 | /.cache 6 | /public/build 7 | /build 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./data.db?connection_limit=1" 2 | SESSION_SECRET="super-duper-s3cret" 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /app/styles/tailwind.css 8 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "env": { 4 | "ENABLE_FILE_SYSTEM_API": "1" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/img/eurodance-bg-no-text.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VulcanJS/eurodance-stack/HEAD/public/img/eurodance-bg-no-text.jpg -------------------------------------------------------------------------------- /mocks/index.js: -------------------------------------------------------------------------------- 1 | require("tsconfig-paths/register"); 2 | require("ts-node").register({ transpileOnly: true }); 3 | require("./start"); 4 | -------------------------------------------------------------------------------- /cypress/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: "./tsconfig.json", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./app/**/*.{ts,tsx,jsx,js}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /remix.init/gitignore: -------------------------------------------------------------------------------- 1 | .yarn/* 2 | node_modules 3 | 4 | /build 5 | /public/build 6 | .env 7 | 8 | /cypress/screenshots 9 | /cypress/videos 10 | /prisma/data.db 11 | /prisma/data.db-journal 12 | 13 | /app/styles/tailwind.css 14 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | # Install Fly 4 | RUN curl -L https://fly.io/install.sh | sh 5 | ENV FLYCTL_INSTALL="/home/gitpod/.fly" 6 | ENV PATH="$FLYCTL_INSTALL/bin:$PATH" 7 | 8 | # Install GitHub CLI 9 | RUN brew install gh 10 | -------------------------------------------------------------------------------- /remix.init/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix.init", 3 | "private": true, 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@iarna/toml": "^2.2.5", 8 | "sort-package-json": "^1.57.0", 9 | "yaml": "^2.1.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/routes/distant-api.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet } from "@remix-run/react"; 2 | 3 | export default function Root() { 4 | return ( 5 |
6 |
7 | Back home 8 |
9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /mocks/start.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | 3 | import "~/utils"; 4 | 5 | const server = setupServer(); 6 | 7 | server.listen({ onUnhandledRequest: "bypass" }); 8 | console.info("🔶 Mock server running"); 9 | 10 | process.once("SIGINT", () => server.close()); 11 | process.once("SIGTERM", () => server.close()); 12 | -------------------------------------------------------------------------------- /.vulcan/scripts/is-monorepo.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/node 2 | function run() { 3 | if ((process.env.PROJECT_CWD || "").match(/vulcan-npm/)) { 4 | process.exit(0); 5 | } else { 6 | process.exit(1); 7 | } 8 | } 9 | run(); 10 | //#  @see https://yarnpkg.com/advanced/lifecycle-scripts/#environment-variables 11 | //[[ "$PROJECT_CWD" == *"vulcan-npm"* ]] 12 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { logout } from "~/session.server"; 5 | 6 | export async function action({ request }: ActionArgs) { 7 | return logout(request); 8 | } 9 | 10 | export async function loader() { 11 | return redirect("/"); 12 | } 13 | -------------------------------------------------------------------------------- /app/routes/notes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | export default function NoteIndexPage() { 4 | return ( 5 |

6 | No note selected. Select a note on the left, or{" "} 7 | 8 | create a new note. 9 | 10 |

11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /mocks/README.md: -------------------------------------------------------------------------------- 1 | # Mocks 2 | 3 | Use this to mock any third party HTTP resources that you don't have running locally and want to have mocked for local development as well as tests. 4 | 5 | Learn more about how to use this at [mswjs.io](https://mswjs.io/) 6 | 7 | For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/start.ts) 8 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This file is how Fly starts the server (configured in fly.toml). Before starting 4 | # the server though, we need to run any prisma migrations that haven't yet been 5 | # run, which is why this file exists in the first place. 6 | # Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386 7 | 8 | set -ex 9 | npx prisma migrate deploy 10 | npm run start 11 | -------------------------------------------------------------------------------- /vercel.server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * From DNB stack 3 | * @see https://github.com/robipop22/dnb-stack/blob/main/server.js 4 | * Remove this ifle if not using Vercel 5 | */ 6 | import { createRequestHandler } from "@remix-run/vercel"; 7 | import * as build from "@remix-run/dev/server-build"; 8 | 9 | console.debug("Running with vercel.server.js"); 10 | export default createRequestHandler({ build, mode: process.env.NODE_ENV }); 11 | -------------------------------------------------------------------------------- /test/setup-test-env.ts: -------------------------------------------------------------------------------- 1 | import { installGlobals } from "@remix-run/node"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | 4 | // @see https://storybook.js.org/addons/@storybook/testing-react 5 | import { setGlobalConfig } from "@storybook/testing-react"; 6 | // path of your preview.js file 7 | import * as globalStorybookConfig from "../.storybook/preview"; 8 | 9 | installGlobals(); 10 | 11 | setGlobalConfig(globalStorybookConfig); 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from "vite"; 5 | import react from "@vitejs/plugin-react"; 6 | import tsconfigPaths from "vite-tsconfig-paths"; 7 | 8 | export default defineConfig({ 9 | plugins: [react(), tsconfigPaths()], 10 | test: { 11 | globals: true, 12 | environment: "happy-dom", 13 | setupFiles: ["./test/setup-test-env.ts"], 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /app/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { validateEmail } from "./utils"; 2 | 3 | test("validateEmail returns false for non-emails", () => { 4 | expect(validateEmail(undefined)).toBe(false); 5 | expect(validateEmail(null)).toBe(false); 6 | expect(validateEmail("")).toBe(false); 7 | expect(validateEmail("not-an-email")).toBe(false); 8 | expect(validateEmail("n@")).toBe(false); 9 | }); 10 | 11 | test("validateEmail returns true for emails", () => { 12 | expect(validateEmail("kody@example.com")).toBe(true); 13 | }); 14 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: [ 3 | "../stories/**/*.stories.mdx", 4 | "../stories/**/*.stories.@(js|jsx|ts|tsx)", 5 | "../app/**/*.stories.@(js|jsx|ts|tsx|mdx)", 6 | ], 7 | addons: [ 8 | "@storybook/addon-links", 9 | "@storybook/addon-essentials", 10 | "@storybook/addon-interactions", 11 | ], 12 | framework: "@storybook/react", 13 | core: { 14 | builder: "@storybook/builder-vite", 15 | }, 16 | features: { 17 | storyStoreV7: true, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "@remix-run/eslint-config/jest-testing-library", "prettier", "plugin:storybook/recommended"], 6 | // we're using vitest which has a very similar API to jest 7 | // (so the linting plugins work nicely), but it means we have to explicitly 8 | // set the jest version. 9 | settings: { 10 | jest: { 11 | version: 27 12 | } 13 | } 14 | }; -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RemixBrowser } from "@remix-run/react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | function hydrate() { 6 | React.startTransition(() => { 7 | hydrateRoot( 8 | document, 9 | 10 | 11 | 12 | ); 13 | }); 14 | } 15 | 16 | if (window.requestIdleCallback) { 17 | window.requestIdleCallback(hydrate) 18 | } else { 19 | window.setTimeout(hydrate, 1) 20 | } 21 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/cypress/add-commands"; 2 | import "./commands"; 3 | 4 | Cypress.on("uncaught:exception", (err) => { 5 | // Cypress and React Hydrating the document don't get along 6 | // for some unknown reason. Hopefully we figure out why eventually 7 | // so we can remove this. 8 | if ( 9 | /hydrat/i.test(err.message) || 10 | /Minified React error #418/.test(err.message) || 11 | /Minified React error #423/.test(err.message) 12 | ) { 13 | return false; 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /cypress/screenshots 8 | /cypress/videos 9 | /prisma/data.db 10 | /prisma/data.db-journal 11 | 12 | /app/styles/tailwind.css 13 | 14 | 15 | # Yarn 2 16 | # https://stackoverflow.com/questions/62456078/yarn-install-leads-to-cannot-find-module-yarn-berry-js 17 | .yarn/* 18 | .pnp.* 19 | # Keep those in the repo to have a share version 20 | !.yarn/patches 21 | !.yarn/releases 22 | !.yarn/plugins 23 | !.yarn/sdks 24 | !.yarn/versions 25 | 26 | # Add ignoring built server 27 | /api -------------------------------------------------------------------------------- /app/components/tests/GithubButtons.stories.tsx: -------------------------------------------------------------------------------- 1 | // Button.stories.ts|tsx 2 | import type { ComponentStory, ComponentMeta } from "@storybook/react"; 3 | 4 | import { GithubButtons } from "../GithubButtons"; 5 | 6 | export default { 7 | /* 👇 The title prop is optional. 8 | * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading 9 | * to learn how to generate automatic titles 10 | */ 11 | title: "GithubButtons", 12 | component: GithubButtons, 13 | } as ComponentMeta; 14 | 15 | export const Basic: ComponentStory = () => ( 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /app/components/tests/GithubButtons.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { GithubButtons } from "../GithubButtons"; 3 | import { composeStories } from "@storybook/testing-react"; 4 | import * as stories from "./GithubButtons.stories"; // import all stories from the stories file 5 | 6 | const { Basic } = composeStories(stories); 7 | 8 | test("Render GithubButtons", () => { 9 | render(); 10 | expect(document.querySelector("section")).toBeDefined(); 11 | }); 12 | 13 | test("Render GithubButtons from Storybook", () => { 14 | render(); 15 | expect(document.querySelector("section")).toBeDefined(); 16 | }); 17 | -------------------------------------------------------------------------------- /stories/header.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 4 | padding: 15px 20px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | } 9 | 10 | svg { 11 | display: inline-block; 12 | vertical-align: top; 13 | } 14 | 15 | h1 { 16 | font-weight: 900; 17 | font-size: 20px; 18 | line-height: 1; 19 | margin: 6px 0 6px 10px; 20 | display: inline-block; 21 | vertical-align: top; 22 | } 23 | 24 | button + button { 25 | margin-left: 10px; 26 | } 27 | 28 | .welcome { 29 | color: #333; 30 | font-size: 14px; 31 | margin-right: 10px; 32 | } 33 | -------------------------------------------------------------------------------- /app/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let prisma: PrismaClient; 4 | 5 | declare global { 6 | var __db__: PrismaClient; 7 | } 8 | 9 | // this is needed because in development we don't want to restart 10 | // the server with every change, but we want to make sure we don't 11 | // create a new connection to the DB with every change either. 12 | // in production we'll have a single connection to the DB. 13 | if (process.env.NODE_ENV === "production") { 14 | prisma = new PrismaClient(); 15 | } else { 16 | if (!global.__db__) { 17 | global.__db__ = new PrismaClient(); 18 | } 19 | prisma = global.__db__; 20 | prisma.$connect(); 21 | } 22 | 23 | export { prisma }; 24 | -------------------------------------------------------------------------------- /stories/Header.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { Header } from './Header'; 5 | 6 | export default { 7 | title: 'Example/Header', 8 | component: Header, 9 | parameters: { 10 | // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout 11 | layout: 'fullscreen', 12 | }, 13 | } as ComponentMeta; 14 | 15 | const Template: ComponentStory = (args) =>
; 16 | 17 | export const LoggedIn = Template.bind({}); 18 | LoggedIn.args = { 19 | user: { 20 | name: 'Jane Doe', 21 | }, 22 | }; 23 | 24 | export const LoggedOut = Template.bind({}); 25 | LoggedOut.args = {}; 26 | -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | module.exports = ( 2 | on: Cypress.PluginEvents, 3 | config: Cypress.PluginConfigOptions 4 | ) => { 5 | const isDev = config.watchForFileChanges; 6 | const port = process.env.PORT ?? (isDev ? "3000" : "8811"); 7 | const configOverrides: Partial = { 8 | baseUrl: `http://localhost:${port}`, 9 | integrationFolder: "cypress/e2e", 10 | video: !process.env.CI, 11 | screenshotOnRunFailure: !process.env.CI, 12 | }; 13 | Object.assign(config, configOverrides); 14 | 15 | // To use this: 16 | // cy.task('log', whateverYouWantInTheTerminal) 17 | on("task", { 18 | log(message) { 19 | console.log(message); 20 | return null; 21 | }, 22 | }); 23 | 24 | return config; 25 | }; 26 | -------------------------------------------------------------------------------- /stories/button.css: -------------------------------------------------------------------------------- 1 | .storybook-button { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-weight: 700; 4 | border: 0; 5 | border-radius: 3em; 6 | cursor: pointer; 7 | display: inline-block; 8 | line-height: 1; 9 | } 10 | .storybook-button--primary { 11 | color: white; 12 | background-color: #1ea7fd; 13 | } 14 | .storybook-button--secondary { 15 | color: #333; 16 | background-color: transparent; 17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 18 | } 19 | .storybook-button--small { 20 | font-size: 12px; 21 | padding: 10px 16px; 22 | } 23 | .storybook-button--medium { 24 | font-size: 14px; 25 | padding: 11px 20px; 26 | } 27 | .storybook-button--large { 28 | font-size: 16px; 29 | padding: 12px 24px; 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["./cypress", "./cypress.config.ts"], 3 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 6 | "types": ["vitest/globals"], 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "jsx": "react-jsx", 10 | "module": "CommonJS", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "target": "ES2019", 14 | "strict": true, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "~/*": ["./app/*"] 20 | }, 21 | "skipLibCheck": true, 22 | 23 | // Remix takes care of building everything in `remix build`. 24 | "noEmit": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/components/GithubButtons.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Demo component, just used to demo the Storybook+Vitest workflow 3 | */ 4 | export const GithubButtons = () => ( 5 |
6 | 14 | 22 |
23 | ); 24 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | setupNodeEvents: (on, config) => { 6 | const isDev = config.watchForFileChanges; 7 | const port = process.env.PORT ?? (isDev ? "3000" : "8811"); 8 | const configOverrides: Partial = { 9 | baseUrl: `http://localhost:${port}`, 10 | video: !process.env.CI, 11 | screenshotOnRunFailure: !process.env.CI, 12 | }; 13 | 14 | // To use this: 15 | // cy.task('log', whateverYouWantInTheTerminal) 16 | on("task", { 17 | log: (message) => { 18 | console.log(message); 19 | 20 | return null; 21 | }, 22 | }); 23 | 24 | return { ...config, ...configOverrides }; 25 | }, 26 | }, 27 | }); -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "../node_modules/@types/jest", 4 | "../node_modules/@testing-library/jest-dom" 5 | ], 6 | "include": [ 7 | "./index.ts", 8 | "e2e/**/*", 9 | "plugins/**/*", 10 | "support/**/*", 11 | "../node_modules/cypress", 12 | "../node_modules/@testing-library/cypress" 13 | ], 14 | "compilerOptions": { 15 | "baseUrl": ".", 16 | "noEmit": true, 17 | "types": ["node", "cypress", "@testing-library/cypress"], 18 | "esModuleInterop": true, 19 | "jsx": "react-jsx", 20 | "moduleResolution": "node", 21 | "target": "es2019", 22 | "strict": true, 23 | "skipLibCheck": true, 24 | "resolveJsonModule": true, 25 | "typeRoots": ["../types", "../node_modules/@types"], 26 | 27 | "paths": { 28 | "~/*": ["../app/*"] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "sqlite" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | model User { 11 | id String @id @default(cuid()) 12 | email String @unique 13 | 14 | createdAt DateTime @default(now()) 15 | updatedAt DateTime @updatedAt 16 | 17 | password Password? 18 | notes Note[] 19 | } 20 | 21 | model Password { 22 | hash String 23 | 24 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 25 | userId String @unique 26 | } 27 | 28 | model Note { 29 | id String @id @default(cuid()) 30 | title String 31 | body String 32 | 33 | createdAt DateTime @default(now()) 34 | updatedAt DateTime @updatedAt 35 | 36 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 37 | userId String 38 | } 39 | -------------------------------------------------------------------------------- /app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks 2 | import type { LoaderArgs } from "@remix-run/node"; 3 | 4 | import { prisma } from "~/db.server"; 5 | 6 | export async function loader({ request }: LoaderArgs) { 7 | const host = 8 | request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); 9 | 10 | try { 11 | const url = new URL("/", `http://${host}`); 12 | // if we can connect to the database and make a simple query 13 | // and make a HEAD request to ourselves, then we're good. 14 | await Promise.all([ 15 | prisma.user.count(), 16 | fetch(url.toString(), { method: "HEAD" }).then((r) => { 17 | if (!r.ok) return Promise.reject(r); 18 | }), 19 | ]); 20 | return new Response("OK"); 21 | } catch (error: unknown) { 22 | console.log("healthcheck ❌", { error }); 23 | return new Response("ERROR", { status: 500 }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /stories/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { within, userEvent } from '@storybook/testing-library'; 4 | import { Page } from './Page'; 5 | 6 | export default { 7 | title: 'Example/Page', 8 | component: Page, 9 | parameters: { 10 | // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout 11 | layout: 'fullscreen', 12 | }, 13 | } as ComponentMeta; 14 | 15 | const Template: ComponentStory = (args) => ; 16 | 17 | export const LoggedOut = Template.bind({}); 18 | 19 | export const LoggedIn = Template.bind({}); 20 | 21 | // More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing 22 | LoggedIn.play = async ({ canvasElement }) => { 23 | const canvas = within(canvasElement); 24 | const loginButton = await canvas.getByRole('button', { name: /Log in/i }); 25 | await userEvent.click(loginButton); 26 | }; 27 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | cacheDirectory: "./node_modules/.cache/remix", 6 | ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"], 7 | /** 8 | * Remove if not using Vercel 9 | * @see https://remix.run/docs/en/v1/api/conventions#serverbuildtarget 10 | * */ 11 | serverBuildTarget: "vercel", 12 | /** 13 | * Remove if not using Vercel 14 | * 15 | * Inspiredf from DNB stack. 16 | * 17 | * @example 18 | * yarn run dev => remix dev server 19 | * yarn run build => use remix prod server, locally 20 | * VERCEL=1 yarn run build => use vercel server, locally 21 | * On Vercel: => yarn run build will use vercel server (VERCEL is automatically set) 22 | * 23 | * @see https://vercel.com/docs/concepts/projects/environment-variables 24 | * 25 | * @see https://github.com/robipop22/dnb-stack/blob/main/remix.config.js 26 | */ 27 | server: process.env.VERCEL ? "./vercel.server.js" : undefined, 28 | }; 29 | -------------------------------------------------------------------------------- /cypress/support/delete-user.ts: -------------------------------------------------------------------------------- 1 | // Use this to delete a user by their email 2 | // Simply call this with: 3 | // npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts username@example.com 4 | // and that user will get deleted 5 | 6 | import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; 7 | import { installGlobals } from "@remix-run/node"; 8 | import { prisma } from "~/db.server"; 9 | 10 | installGlobals(); 11 | 12 | async function deleteUser(email: string) { 13 | if (!email) { 14 | throw new Error("email required for login"); 15 | } 16 | if (!email.endsWith("@example.com")) { 17 | throw new Error("All test emails must end in @example.com"); 18 | } 19 | 20 | try { 21 | await prisma.user.delete({ where: { email } }); 22 | } catch (error) { 23 | if ( 24 | error instanceof PrismaClientKnownRequestError && 25 | error.code === "P2025" 26 | ) { 27 | console.log("User not found, so no need to delete"); 28 | } else { 29 | throw error; 30 | } 31 | } 32 | } 33 | 34 | deleteUser(process.argv[2]); 35 | -------------------------------------------------------------------------------- /prisma/migrations/20220307190657_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "email" TEXT NOT NULL, 5 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" DATETIME NOT NULL 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Password" ( 11 | "hash" TEXT NOT NULL, 12 | "userId" TEXT NOT NULL, 13 | CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "Note" ( 18 | "id" TEXT NOT NULL PRIMARY KEY, 19 | "title" TEXT NOT NULL, 20 | "body" TEXT NOT NULL, 21 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | "updatedAt" DATETIME NOT NULL, 23 | "userId" TEXT NOT NULL, 24 | CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 25 | ); 26 | 27 | -- CreateIndex 28 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 29 | 30 | -- CreateIndex 31 | CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId"); 32 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "vulcan-remix" 2 | kill_signal = "SIGINT" 3 | kill_timeout = 5 4 | processes = [] 5 | 6 | [experimental] 7 | allowed_public_ports = [] 8 | auto_rollback = true 9 | cmd = "start.sh" 10 | entrypoint = "sh" 11 | 12 | [mounts] 13 | source = "data" 14 | destination = "/data" 15 | 16 | [[services]] 17 | internal_port = 8080 18 | processes = ["app"] 19 | protocol = "tcp" 20 | script_checks = [] 21 | 22 | [services.concurrency] 23 | hard_limit = 25 24 | soft_limit = 20 25 | type = "connections" 26 | 27 | [[services.ports]] 28 | handlers = ["http"] 29 | port = 80 30 | force_https = true 31 | 32 | [[services.ports]] 33 | handlers = ["tls", "http"] 34 | port = 443 35 | 36 | [[services.tcp_checks]] 37 | grace_period = "1s" 38 | interval = "15s" 39 | restart_limit = 0 40 | timeout = "2s" 41 | 42 | [[services.http_checks]] 43 | interval = "10s" 44 | grace_period = "5s" 45 | method = "get" 46 | path = "/healthcheck" 47 | protocol = "http" 48 | timeout = "2s" 49 | tls_skip_verify = false 50 | [services.http_checks.headers] 51 | -------------------------------------------------------------------------------- /stories/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './button.css'; 3 | 4 | interface ButtonProps { 5 | /** 6 | * Is this the principal call to action on the page? 7 | */ 8 | primary?: boolean; 9 | /** 10 | * What background color to use 11 | */ 12 | backgroundColor?: string; 13 | /** 14 | * How large should the button be? 15 | */ 16 | size?: 'small' | 'medium' | 'large'; 17 | /** 18 | * Button contents 19 | */ 20 | label: string; 21 | /** 22 | * Optional click handler 23 | */ 24 | onClick?: () => void; 25 | } 26 | 27 | /** 28 | * Primary UI component for user interaction 29 | */ 30 | export const Button = ({ 31 | primary = false, 32 | size = 'medium', 33 | backgroundColor, 34 | label, 35 | ...props 36 | }: ButtonProps) => { 37 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; 38 | return ( 39 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /app/models/note.server.ts: -------------------------------------------------------------------------------- 1 | import type { User, Note } from "@prisma/client"; 2 | 3 | import { prisma } from "~/db.server"; 4 | 5 | export type { Note } from "@prisma/client"; 6 | 7 | export function getNote({ 8 | id, 9 | userId, 10 | }: Pick & { 11 | userId: User["id"]; 12 | }) { 13 | return prisma.note.findFirst({ 14 | select: { id: true, body: true, title: true }, 15 | where: { id, userId }, 16 | }); 17 | } 18 | 19 | export function getNoteListItems({ userId }: { userId: User["id"] }) { 20 | return prisma.note.findMany({ 21 | where: { userId }, 22 | select: { id: true, title: true }, 23 | orderBy: { updatedAt: "desc" }, 24 | }); 25 | } 26 | 27 | export function createNote({ 28 | body, 29 | title, 30 | userId, 31 | }: Pick & { 32 | userId: User["id"]; 33 | }) { 34 | return prisma.note.create({ 35 | data: { 36 | title, 37 | body, 38 | user: { 39 | connect: { 40 | id: userId, 41 | }, 42 | }, 43 | }, 44 | }); 45 | } 46 | 47 | export function deleteNote({ 48 | id, 49 | userId, 50 | }: Pick & { userId: User["id"] }) { 51 | return prisma.note.deleteMany({ 52 | where: { id, userId }, 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import bcrypt from "bcryptjs"; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | async function seed() { 7 | const email = "rachel@remix.run"; 8 | 9 | // cleanup the existing database 10 | await prisma.user.delete({ where: { email } }).catch(() => { 11 | // no worries if it doesn't exist yet 12 | }); 13 | 14 | const hashedPassword = await bcrypt.hash("racheliscool", 10); 15 | 16 | const user = await prisma.user.create({ 17 | data: { 18 | email, 19 | password: { 20 | create: { 21 | hash: hashedPassword, 22 | }, 23 | }, 24 | }, 25 | }); 26 | 27 | await prisma.note.create({ 28 | data: { 29 | title: "My first note", 30 | body: "Hello, world!", 31 | userId: user.id, 32 | }, 33 | }); 34 | 35 | await prisma.note.create({ 36 | data: { 37 | title: "My second note", 38 | body: "Hello, world!", 39 | userId: user.id, 40 | }, 41 | }); 42 | 43 | console.log(`Database has been seeded. 🌱`); 44 | } 45 | 46 | seed() 47 | .catch((e) => { 48 | console.error(e); 49 | process.exit(1); 50 | }) 51 | .finally(async () => { 52 | await prisma.$disconnect(); 53 | }); 54 | -------------------------------------------------------------------------------- /stories/assets/direction.svg: -------------------------------------------------------------------------------- 1 | illustration/direction -------------------------------------------------------------------------------- /stories/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { Button } from './Button'; 5 | 6 | // More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export 7 | export default { 8 | title: 'Example/Button', 9 | component: Button, 10 | // More on argTypes: https://storybook.js.org/docs/react/api/argtypes 11 | argTypes: { 12 | backgroundColor: { control: 'color' }, 13 | }, 14 | } as ComponentMeta; 15 | 16 | // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args 17 | const Template: ComponentStory = (args) =>
56 | ); 57 | -------------------------------------------------------------------------------- /app/routes/notes/$noteId.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, LoaderArgs } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | import { Form, useCatch, useLoaderData } from "@remix-run/react"; 4 | import invariant from "tiny-invariant"; 5 | 6 | import { deleteNote } from "~/models/note.server"; 7 | import { getNote } from "~/models/note.server"; 8 | import { requireUserId } from "~/session.server"; 9 | 10 | export async function loader({ request, params }: LoaderArgs) { 11 | const userId = await requireUserId(request); 12 | invariant(params.noteId, "noteId not found"); 13 | 14 | const note = await getNote({ userId, id: params.noteId }); 15 | if (!note) { 16 | throw new Response("Not Found", { status: 404 }); 17 | } 18 | return json({ note }); 19 | } 20 | 21 | export async function action({ request, params }: ActionArgs) { 22 | const userId = await requireUserId(request); 23 | invariant(params.noteId, "noteId not found"); 24 | 25 | await deleteNote({ userId, id: params.noteId }); 26 | 27 | return redirect("/notes"); 28 | } 29 | 30 | export default function NoteDetailsPage() { 31 | const data = useLoaderData(); 32 | 33 | return ( 34 |
35 |

{data.note.title}

36 |

{data.note.body}

37 |
38 |
39 | 45 |
46 |
47 | ); 48 | } 49 | 50 | export function ErrorBoundary({ error }: { error: Error }) { 51 | console.error(error); 52 | 53 | return
An unexpected error occurred: {error.message}
; 54 | } 55 | 56 | export function CatchBoundary() { 57 | const caught = useCatch(); 58 | 59 | if (caught.status === 404) { 60 | return
Note not found
; 61 | } 62 | 63 | throw new Error(`Unexpected caught response with status: ${caught.status}`); 64 | } 65 | -------------------------------------------------------------------------------- /stories/assets/plugin.svg: -------------------------------------------------------------------------------- 1 | illustration/plugin -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | 3 | declare global { 4 | namespace Cypress { 5 | interface Chainable { 6 | /** 7 | * Logs in with a random user. Yields the user and adds an alias to the user 8 | * 9 | * @returns {typeof login} 10 | * @memberof Chainable 11 | * @example 12 | * cy.login() 13 | * @example 14 | * cy.login({ email: 'whatever@example.com' }) 15 | */ 16 | login: typeof login; 17 | 18 | /** 19 | * Deletes the current @user 20 | * 21 | * @returns {typeof cleanupUser} 22 | * @memberof Chainable 23 | * @example 24 | * cy.cleanupUser() 25 | * @example 26 | * cy.cleanupUser({ email: 'whatever@example.com' }) 27 | */ 28 | cleanupUser: typeof cleanupUser; 29 | } 30 | } 31 | } 32 | 33 | function login({ 34 | email = faker.internet.email(undefined, undefined, "example.com"), 35 | }: { 36 | email?: string; 37 | } = {}) { 38 | cy.then(() => ({ email })).as("user"); 39 | cy.exec( 40 | `npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts "${email}"` 41 | ).then(({ stdout }) => { 42 | const cookieValue = stdout 43 | .replace(/.*(?.*)<\/cookie>.*/s, "$") 44 | .trim(); 45 | cy.setCookie("__session", cookieValue); 46 | }); 47 | return cy.get("@user"); 48 | } 49 | 50 | function cleanupUser({ email }: { email?: string } = {}) { 51 | if (email) { 52 | deleteUserByEmail(email); 53 | } else { 54 | cy.get("@user").then((user) => { 55 | const email = (user as { email?: string }).email; 56 | if (email) { 57 | deleteUserByEmail(email); 58 | } 59 | }); 60 | } 61 | cy.clearCookie("__session"); 62 | } 63 | 64 | function deleteUserByEmail(email: string) { 65 | cy.exec( 66 | `npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts "${email}"` 67 | ); 68 | cy.clearCookie("__session"); 69 | } 70 | 71 | Cypress.Commands.add("login", login); 72 | Cypress.Commands.add("cleanupUser", cleanupUser); 73 | 74 | /* 75 | eslint 76 | @typescript-eslint/no-namespace: "off", 77 | */ 78 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | import { useMatches } from "@remix-run/react"; 2 | import { useMemo } from "react"; 3 | 4 | import type { User } from "~/models/user.server"; 5 | 6 | const DEFAULT_REDIRECT = "/"; 7 | 8 | /** 9 | * This should be used any time the redirect path is user-provided 10 | * (Like the query string on our login/signup pages). This avoids 11 | * open-redirect vulnerabilities. 12 | * @param {string} to The redirect destination 13 | * @param {string} defaultRedirect The redirect to use if the to is unsafe. 14 | */ 15 | export function safeRedirect( 16 | to: FormDataEntryValue | string | null | undefined, 17 | defaultRedirect: string = DEFAULT_REDIRECT 18 | ) { 19 | if (!to || typeof to !== "string") { 20 | return defaultRedirect; 21 | } 22 | 23 | if (!to.startsWith("/") || to.startsWith("//")) { 24 | return defaultRedirect; 25 | } 26 | 27 | return to; 28 | } 29 | 30 | /** 31 | * This base hook is used in other hooks to quickly search for specific data 32 | * across all loader data using useMatches. 33 | * @param {string} id The route id 34 | * @returns {JSON|undefined} The router data or undefined if not found 35 | */ 36 | export function useMatchesData( 37 | id: string 38 | ): Record | undefined { 39 | const matchingRoutes = useMatches(); 40 | const route = useMemo( 41 | () => matchingRoutes.find((route) => route.id === id), 42 | [matchingRoutes, id] 43 | ); 44 | return route?.data; 45 | } 46 | 47 | function isUser(user: any): user is User { 48 | return user && typeof user === "object" && typeof user.email === "string"; 49 | } 50 | 51 | export function useOptionalUser(): User | undefined { 52 | const data = useMatchesData("root"); 53 | if (!data || !isUser(data.user)) { 54 | return undefined; 55 | } 56 | return data.user; 57 | } 58 | 59 | export function useUser(): User { 60 | const maybeUser = useOptionalUser(); 61 | if (!maybeUser) { 62 | throw new Error( 63 | "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead." 64 | ); 65 | } 66 | return maybeUser; 67 | } 68 | 69 | export function validateEmail(email: unknown): email is string { 70 | return typeof email === "string" && email.length > 3 && email.includes("@"); 71 | } 72 | -------------------------------------------------------------------------------- /app/routes/notes.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; 4 | 5 | import { requireUserId } from "~/session.server"; 6 | import { useUser } from "~/utils"; 7 | import { getNoteListItems } from "~/models/note.server"; 8 | 9 | export async function loader({ request }: LoaderArgs) { 10 | const userId = await requireUserId(request); 11 | const noteListItems = await getNoteListItems({ userId }); 12 | return json({ noteListItems }); 13 | } 14 | 15 | export default function NotesPage() { 16 | const data = useLoaderData(); 17 | const user = useUser(); 18 | 19 | return ( 20 |
21 |
22 |

23 | Notes 24 |

25 |

{user.email}

26 |
27 | 33 |
34 |
35 | 36 |
37 |
38 | 39 | + New Note 40 | 41 | 42 |
43 | 44 | {data.noteListItems.length === 0 ? ( 45 |

No notes yet

46 | ) : ( 47 |
    48 | {data.noteListItems.map((note) => ( 49 |
  1. 50 | 52 | `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` 53 | } 54 | to={note.id} 55 | > 56 | 📝 {note.title} 57 | 58 |
  2. 59 | ))} 60 |
61 | )} 62 |
63 | 64 |
65 | 66 |
67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /stories/assets/stackalt.svg: -------------------------------------------------------------------------------- 1 | illustration/stackalt -------------------------------------------------------------------------------- /app/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, redirect } from "@remix-run/node"; 2 | import invariant from "tiny-invariant"; 3 | 4 | import type { User } from "~/models/user.server"; 5 | import { getUserById } from "~/models/user.server"; 6 | 7 | invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); 8 | 9 | export const sessionStorage = createCookieSessionStorage({ 10 | cookie: { 11 | name: "__session", 12 | httpOnly: true, 13 | path: "/", 14 | sameSite: "lax", 15 | secrets: [process.env.SESSION_SECRET], 16 | secure: process.env.NODE_ENV === "production", 17 | }, 18 | }); 19 | 20 | const USER_SESSION_KEY = "userId"; 21 | 22 | export async function getSession(request: Request) { 23 | const cookie = request.headers.get("Cookie"); 24 | return sessionStorage.getSession(cookie); 25 | } 26 | 27 | export async function getUserId( 28 | request: Request 29 | ): Promise { 30 | const session = await getSession(request); 31 | const userId = session.get(USER_SESSION_KEY); 32 | return userId; 33 | } 34 | 35 | export async function getUser(request: Request) { 36 | const userId = await getUserId(request); 37 | if (userId === undefined) return null; 38 | 39 | const user = await getUserById(userId); 40 | if (user) return user; 41 | 42 | throw await logout(request); 43 | } 44 | 45 | export async function requireUserId( 46 | request: Request, 47 | redirectTo: string = new URL(request.url).pathname 48 | ) { 49 | const userId = await getUserId(request); 50 | if (!userId) { 51 | const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); 52 | throw redirect(`/login?${searchParams}`); 53 | } 54 | return userId; 55 | } 56 | 57 | export async function requireUser(request: Request) { 58 | const userId = await requireUserId(request); 59 | 60 | const user = await getUserById(userId); 61 | if (user) return user; 62 | 63 | throw await logout(request); 64 | } 65 | 66 | export async function createUserSession({ 67 | request, 68 | userId, 69 | remember, 70 | redirectTo, 71 | }: { 72 | request: Request; 73 | userId: string; 74 | remember: boolean; 75 | redirectTo: string; 76 | }) { 77 | const session = await getSession(request); 78 | session.set(USER_SESSION_KEY, userId); 79 | return redirect(redirectTo, { 80 | headers: { 81 | "Set-Cookie": await sessionStorage.commitSession(session, { 82 | maxAge: remember 83 | ? 60 * 60 * 24 * 7 // 7 days 84 | : undefined, 85 | }), 86 | }, 87 | }); 88 | } 89 | 90 | export async function logout(request: Request) { 91 | const session = await getSession(request); 92 | return redirect("/", { 93 | headers: { 94 | "Set-Cookie": await sessionStorage.destroySession(session), 95 | }, 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /stories/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Header } from './Header'; 4 | import './page.css'; 5 | 6 | type User = { 7 | name: string; 8 | }; 9 | 10 | export const Page: React.VFC = () => { 11 | const [user, setUser] = React.useState(); 12 | 13 | return ( 14 |
15 |
setUser({ name: 'Jane Doe' })} 18 | onLogout={() => setUser(undefined)} 19 | onCreateAccount={() => setUser({ name: 'Jane Doe' })} 20 | /> 21 | 22 |
23 |

Pages in Storybook

24 |

25 | We recommend building UIs with a{' '} 26 | 27 | component-driven 28 | {' '} 29 | process starting with atomic components and ending with pages. 30 |

31 |

32 | Render pages with mock data. This makes it easy to build and review page states without 33 | needing to navigate to them in your app. Here are some handy patterns for managing page 34 | data in Storybook: 35 |

36 |
    37 |
  • 38 | Use a higher-level connected component. Storybook helps you compose such data from the 39 | "args" of child component stories 40 |
  • 41 |
  • 42 | Assemble data in the page component from your services. You can mock these services out 43 | using Storybook. 44 |
  • 45 |
46 |

47 | Get a guided tutorial on component-driven development at{' '} 48 | 49 | Storybook tutorials 50 | 51 | . Read more in the{' '} 52 | 53 | docs 54 | 55 | . 56 |

57 |
58 | Tip Adjust the width of the canvas with the{' '} 59 | 60 | 61 | 66 | 67 | 68 | Viewports addon in the toolbar 69 |
70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /app/routes/notes/new.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | import { Form, useActionData } from "@remix-run/react"; 4 | import * as React from "react"; 5 | 6 | import { createNote } from "~/models/note.server"; 7 | import { requireUserId } from "~/session.server"; 8 | 9 | export async function action({ request }: ActionArgs) { 10 | const userId = await requireUserId(request); 11 | 12 | const formData = await request.formData(); 13 | const title = formData.get("title"); 14 | const body = formData.get("body"); 15 | 16 | if (typeof title !== "string" || title.length === 0) { 17 | return json( 18 | { errors: { title: "Title is required", body: null } }, 19 | { status: 400 } 20 | ); 21 | } 22 | 23 | if (typeof body !== "string" || body.length === 0) { 24 | return json( 25 | { errors: { title: null, body: "Body is required" } }, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | const note = await createNote({ title, body, userId }); 31 | 32 | return redirect(`/notes/${note.id}`); 33 | } 34 | 35 | export default function NewNotePage() { 36 | const actionData = useActionData(); 37 | const titleRef = React.useRef(null); 38 | const bodyRef = React.useRef(null); 39 | 40 | React.useEffect(() => { 41 | if (actionData?.errors?.title) { 42 | titleRef.current?.focus(); 43 | } else if (actionData?.errors?.body) { 44 | bodyRef.current?.focus(); 45 | } 46 | }, [actionData]); 47 | 48 | return ( 49 |
58 |
59 | 71 | {actionData?.errors?.title && ( 72 |
73 | {actionData.errors.title} 74 |
75 | )} 76 |
77 | 78 |
79 |