├── .env.example
├── .eslintrc.cjs
├── .github
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── deploy.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc.js
├── .vscode
├── extensions.json
├── remix.code-snippets
└── settings.json
├── README.md
├── app
├── components
│ ├── error-boundary.tsx
│ ├── floating-toolbar.tsx
│ ├── forms.tsx
│ ├── progress-bar.tsx
│ ├── search-bar.tsx
│ ├── spacer.tsx
│ ├── toaster.tsx
│ └── ui
│ │ ├── README.md
│ │ ├── button.tsx
│ │ ├── checkbox.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── icon.tsx
│ │ ├── icons
│ │ ├── README.md
│ │ ├── name.d.ts
│ │ └── sprite.svg
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── sonner.tsx
│ │ ├── status-button.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
├── entry.client.tsx
├── entry.server.tsx
├── root.tsx
├── routes
│ ├── $.tsx
│ ├── _marketing+
│ │ ├── get-routes.server.ts
│ │ ├── index.tsx
│ │ ├── privacy.tsx
│ │ └── tos.tsx
│ ├── _seo+
│ │ ├── robots[.]txt.ts
│ │ └── sitemap[.]xml.ts
│ ├── admin+
│ │ ├── cache.tsx
│ │ ├── cache_.lru.$cacheKey.ts
│ │ ├── cache_.sqlite.$cacheKey.ts
│ │ └── cache_.sqlite.tsx
│ ├── tips+
│ │ ├── background-clip-text.tsx
│ │ ├── blend-modes.tsx
│ │ ├── color-spreads.tsx
│ │ ├── direct-children.tsx
│ │ ├── drop-shadow.tsx
│ │ ├── glassmorphism.tsx
│ │ ├── hamburger-menu.tsx
│ │ ├── has.tsx
│ │ ├── iphone-cards.tsx
│ │ ├── mix-blend-mode-text-clip.tsx
│ │ ├── mouse-fairy.tsx
│ │ ├── nested-absolute-positions.tsx
│ │ └── reduced-motion.tsx
│ └── tutorials+
│ │ ├── fluid-hover-cards+
│ │ ├── index.tsx
│ │ ├── steps+
│ │ │ ├── 01.tsx
│ │ │ ├── 02.tsx
│ │ │ ├── 03.tsx
│ │ │ ├── 04.tsx
│ │ │ ├── 05.tsx
│ │ │ ├── 06.tsx
│ │ │ ├── 07.tsx
│ │ │ ├── 08.tsx
│ │ │ ├── 09.tsx
│ │ │ ├── 10.tsx
│ │ │ ├── 11.tsx
│ │ │ └── index.tsx
│ │ └── steps
│ │ │ └── 01.tsx
│ │ ├── gsap-rockets.tsx
│ │ ├── motion-one-stagger.tsx
│ │ └── work-assistant.tsx
├── styles
│ └── tailwind.css
├── tailwind-presets
│ └── image-text.ts
└── utils
│ ├── auth.server.ts
│ ├── cache.server.ts
│ ├── client-hints.tsx
│ ├── connections.server.ts
│ ├── connections.tsx
│ ├── csrf.server.ts
│ ├── db.server.ts
│ ├── email.server.ts
│ ├── env.server.ts
│ ├── extended-theme.ts
│ ├── format-step.ts
│ ├── get-routes.server.ts
│ ├── honeypot.server.ts
│ ├── litefs.server.ts
│ ├── misc.error-message.test.ts
│ ├── misc.tsx
│ ├── misc.use-double-check.test.tsx
│ ├── monitoring.client.tsx
│ ├── monitoring.server.ts
│ ├── nonce-provider.ts
│ ├── permissions.ts
│ ├── providers
│ ├── github.server.ts
│ └── provider.ts
│ ├── redirect-cookie.server.ts
│ ├── request-info.ts
│ ├── session.server.ts
│ ├── theme.server.ts
│ ├── timing.server.ts
│ ├── toast.server.ts
│ ├── totp.server.ts
│ ├── user-validation.ts
│ ├── user.ts
│ └── verification.server.ts
├── components.json
├── fly.toml
├── index.js
├── other
├── .dockerignore
├── Dockerfile
├── README.md
├── build-icons.ts
├── build-server.ts
├── litefs.yml
├── sentry-create-release.js
├── setup-swap.js
├── sly
│ ├── sly.json
│ └── transform-icon.ts
└── svg-icons
│ ├── README.md
│ ├── arrow-left.svg
│ ├── arrow-right.svg
│ ├── avatar.svg
│ ├── camera.svg
│ ├── check.svg
│ ├── clock.svg
│ ├── cross-1.svg
│ ├── dots-horizontal.svg
│ ├── download.svg
│ ├── envelope-closed.svg
│ ├── exit.svg
│ ├── file-text.svg
│ ├── github-logo.svg
│ ├── laptop.svg
│ ├── link-2.svg
│ ├── lock-closed.svg
│ ├── lock-open-1.svg
│ ├── magnifying-glass.svg
│ ├── moon.svg
│ ├── pencil-1.svg
│ ├── pencil-2.svg
│ ├── plus.svg
│ ├── question-mark-circled.svg
│ ├── reset.svg
│ ├── sun.svg
│ ├── trash.svg
│ └── update.svg
├── package-lock.json
├── package.json
├── playwright.config.ts
├── postcss.config.js
├── prisma
├── migrations
│ ├── 20230914194400_init
│ │ └── migration.sql
│ └── migration_lock.toml
├── schema.prisma
└── seed.ts
├── public
├── favicon.ico
├── favicons
│ ├── README.md
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.svg
│ └── mask-icon.svg
├── img
│ ├── laundry.jpeg
│ ├── space-scene.png
│ └── user.png
└── site.webmanifest
├── remix.config.js
├── server
├── dev-server.js
└── index.ts
├── tailwind.config.ts
├── tests
├── db-utils.ts
├── e2e
│ ├── 2fa.test.ts
│ ├── error-boundary.test.ts
│ ├── note-images.test.ts
│ ├── onboarding.test.ts
│ └── settings-profile.test.ts
├── fixtures
│ ├── github
│ │ └── ghost.jpg
│ └── images
│ │ ├── kody-notes
│ │ ├── cute-koala.png
│ │ ├── koala-coder.png
│ │ ├── koala-cuddle.png
│ │ ├── koala-eating.png
│ │ ├── koala-mentor.png
│ │ ├── koala-soccer.png
│ │ └── mountain.png
│ │ ├── notes
│ │ ├── 0.png
│ │ ├── 1.png
│ │ ├── 2.png
│ │ ├── 3.png
│ │ ├── 4.png
│ │ ├── 5.png
│ │ ├── 6.png
│ │ ├── 7.png
│ │ ├── 8.png
│ │ └── 9.png
│ │ └── user
│ │ ├── 0.jpg
│ │ ├── 1.jpg
│ │ ├── 2.jpg
│ │ ├── 3.jpg
│ │ ├── 4.jpg
│ │ ├── 5.jpg
│ │ ├── 6.jpg
│ │ ├── 7.jpg
│ │ ├── 8.jpg
│ │ ├── 9.jpg
│ │ ├── README.md
│ │ └── kody.png
├── mocks
│ ├── README.md
│ ├── github.ts
│ ├── index.ts
│ ├── resend.ts
│ └── utils.ts
├── playwright-utils.ts
├── setup
│ ├── custom-matchers.ts
│ ├── db-setup.ts
│ ├── global-setup.ts
│ └── setup-test-env.ts
└── utils.ts
├── tsconfig.json
├── types
├── deps.d.ts
├── icon-name.d.ts
├── remix.env.d.ts
└── reset.d.ts
└── vitest.config.ts
/.env.example:
--------------------------------------------------------------------------------
1 | LITEFS_DIR="/litefs/data"
2 | DATABASE_PATH="./prisma/data.db"
3 | DATABASE_URL="file:./data.db?connection_limit=1"
4 | CACHE_DATABASE_PATH="./other/cache.db"
5 | SESSION_SECRET="super-duper-s3cret"
6 | HONEYPOT_SECRET="super-duper-s3cret"
7 | INTERNAL_COMMAND_TOKEN="some-made-up-token"
8 | RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh"
9 | SENTRY_DSN="your-dsn"
10 |
11 | # the mocks and some code rely on these two being prefixed with "MOCK_"
12 | # if they aren't then the real github api will be attempted
13 | GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID"
14 | GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET"
15 | GITHUB_TOKEN="MOCK_GITHUB_TOKEN"
16 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | const vitestFiles = ['app/**/__tests__/**/*', 'app/**/*.{spec,test}.*']
2 | const testFiles = ['**/tests/**', ...vitestFiles]
3 | const appFiles = ['app/**']
4 |
5 | /** @type {import('@types/eslint').Linter.BaseConfig} */
6 | module.exports = {
7 | extends: [
8 | '@remix-run/eslint-config',
9 | '@remix-run/eslint-config/node',
10 | 'prettier',
11 | ],
12 | rules: {
13 | // playwright requires destructuring in fixtures even if you don't use anything 🤷♂️
14 | 'no-empty-pattern': 'off',
15 | '@typescript-eslint/consistent-type-imports': [
16 | 'warn',
17 | {
18 | prefer: 'type-imports',
19 | disallowTypeAnnotations: true,
20 | fixStyle: 'inline-type-imports',
21 | },
22 | ],
23 | 'import/no-duplicates': ['warn', { 'prefer-inline': true }],
24 | 'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
25 | 'import/order': [
26 | 'warn',
27 | {
28 | alphabetize: { order: 'asc', caseInsensitive: true },
29 | groups: [
30 | 'builtin',
31 | 'external',
32 | 'internal',
33 | 'parent',
34 | 'sibling',
35 | 'index',
36 | ],
37 | },
38 | ],
39 | },
40 | overrides: [
41 | {
42 | plugins: ['remix-react-routes'],
43 | files: appFiles,
44 | excludedFiles: testFiles,
45 | rules: {
46 | 'remix-react-routes/use-link-for-routes': 'error',
47 | 'remix-react-routes/require-valid-paths': 'error',
48 | // disable this one because it doesn't appear to work with our
49 | // route convention. Someone should dig deeper into this...
50 | 'remix-react-routes/no-relative-paths': [
51 | 'off',
52 | { allowLinksToSelf: true },
53 | ],
54 | 'remix-react-routes/no-urls': 'error',
55 | 'no-restricted-imports': [
56 | 'error',
57 | {
58 | patterns: [
59 | {
60 | group: testFiles,
61 | message: 'Do not import test files in app files',
62 | },
63 | ],
64 | },
65 | ],
66 | },
67 | },
68 | {
69 | extends: ['@remix-run/eslint-config/jest-testing-library'],
70 | files: vitestFiles,
71 | rules: {
72 | 'testing-library/no-await-sync-events': 'off',
73 | 'jest-dom/prefer-in-document': 'off',
74 | },
75 | // we're using vitest which has a very similar API to jest
76 | // (so the linting plugins work nicely), but it means we have to explicitly
77 | // set the jest version.
78 | settings: {
79 | jest: {
80 | version: 28,
81 | },
82 | },
83 | },
84 | ],
85 | }
86 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Test Plan
4 |
5 |
6 |
7 | ## Checklist
8 |
9 | - [ ] Tests updated
10 | - [ ] Docs updated
11 |
12 | ## Screenshots
13 |
14 |
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_store
3 |
4 | /build
5 | /public/build
6 | /server-build
7 | .env
8 |
9 | /prisma/data.db
10 | /prisma/data.db-journal
11 | /tests/prisma
12 |
13 | /test-results/
14 | /playwright-report/
15 | /playwright/.cache/
16 | /tests/fixtures/email/
17 | /coverage
18 |
19 | /other/cache.db
20 |
21 | # Easy way to create temporary files/folders that won't accidentally be added to git
22 | *.local.*
23 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 | registry=https://registry.npmjs.org/
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /build
4 | /public/build
5 | /server-build
6 | .env
7 |
8 | /test-results/
9 | /playwright-report/
10 | /playwright/.cache/
11 | /tests/fixtures/email/*.json
12 | /coverage
13 | /prisma/migrations
14 |
15 | package-lock.json
16 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import("prettier").Options} */
2 | export default {
3 | arrowParens: 'avoid',
4 | bracketSameLine: false,
5 | bracketSpacing: true,
6 | embeddedLanguageFormatting: 'auto',
7 | endOfLine: 'lf',
8 | htmlWhitespaceSensitivity: 'css',
9 | insertPragma: false,
10 | jsxSingleQuote: false,
11 | printWidth: 80,
12 | proseWrap: 'always',
13 | quoteProps: 'as-needed',
14 | requirePragma: false,
15 | semi: false,
16 | singleAttributePerLine: false,
17 | singleQuote: true,
18 | tabWidth: 2,
19 | trailingComma: 'all',
20 | useTabs: true,
21 | overrides: [
22 | {
23 | files: ['**/*.json'],
24 | options: {
25 | useTabs: false,
26 | },
27 | },
28 | ],
29 | plugins: ['prettier-plugin-tailwindcss'],
30 | }
31 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "bradlc.vscode-tailwindcss",
4 | "dbaeumer.vscode-eslint",
5 | "esbenp.prettier-vscode",
6 | "prisma.prisma",
7 | "qwtel.sqlite-viewer",
8 | "yoavbls.pretty-ts-errors",
9 | "github.vscode-github-actions"
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/remix.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "loader": {
3 | "prefix": "/loader",
4 | "scope": "typescriptreact,javascriptreact,typescript,javascript",
5 | "body": [
6 | "import { type DataFunctionArgs, json } from \"@remix-run/node\"",
7 | "",
8 | "export async function loader({ request }: DataFunctionArgs) {",
9 | " return json({})",
10 | "}"
11 | ]
12 | },
13 | "action": {
14 | "prefix": "/action",
15 | "scope": "typescriptreact,javascriptreact,typescript,javascript",
16 | "body": [
17 | "import { type DataFunctionArgs, json } from \"@remix-run/node\"",
18 | "",
19 | "export async function action({ request }: DataFunctionArgs) {",
20 | " return json({})",
21 | "}"
22 | ]
23 | },
24 | "default": {
25 | "prefix": "/default",
26 | "scope": "typescriptreact,javascriptreact,typescript,javascript",
27 | "body": [
28 | "export default function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}() {",
29 | " return (",
30 | "
",
31 | "
Unknown Route ",
32 | " ",
33 | " )",
34 | "}"
35 | ]
36 | },
37 | "headers": {
38 | "prefix": "/headers",
39 | "scope": "typescriptreact,javascriptreact,typescript,javascript",
40 | "body": [
41 | "import type { HeadersFunction } from '@remix-run/node'",
42 | "",
43 | "export const headers: HeadersFunction = ({ loaderHeaders }) => ({",
44 | " 'Cache-Control': loaderHeaders.get('Cache-Control') ?? '',",
45 | "})"
46 | ]
47 | },
48 | "links": {
49 | "prefix": "/links",
50 | "scope": "typescriptreact,javascriptreact,typescript,javascript",
51 | "body": [
52 | "import type { LinksFunction } from '@remix-run/node'",
53 | "",
54 | "export const links: LinksFunction = () => {",
55 | " return []",
56 | "}"
57 | ]
58 | },
59 | "meta": {
60 | "prefix": "/meta",
61 | "scope": "typescriptreact,javascriptreact,typescript,javascript",
62 | "body": [
63 | "import type { MetaFunction } from '@remix-run/node'",
64 | "",
65 | "export const meta: MetaFunction = ({ data }) => [{",
66 | " title: 'Title',",
67 | "}]"
68 | ]
69 | },
70 | "shouldRevalidate": {
71 | "prefix": "/shouldRevalidate",
72 | "scope": "typescriptreact,javascriptreact,typescript,javascript",
73 | "body": [
74 | "import type { ShouldRevalidateFunction } from '@remix-run/react'",
75 | "",
76 | "export const shouldRevalidate: ShouldRevalidateFunction = ({",
77 | " defaultShouldRevalidate",
78 | "}) => {",
79 | " return defaultShouldRevalidate",
80 | "}"
81 | ]
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.preferences.autoImportFileExcludePatterns": [
3 | "@remix-run/server-runtime",
4 | "@remix-run/router",
5 | "express",
6 | "@radix-ui/**",
7 | "@react-email/**",
8 | "react-router-dom",
9 | "react-router",
10 | "stream/consumers",
11 | "node:stream/consumers",
12 | "node:test",
13 | "console",
14 | "node:console"
15 | ],
16 | "workbench.editorAssociations": {
17 | "*.db": "sqlite-viewer.view"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Ditch analysis paralysis and start shipping Epic Web apps.
5 |
6 |
7 | This is an opinionated project starter and reference that allows teams to
8 | ship their ideas to production faster and on a more stable foundation based
9 | on the experience of Kent C. Dodds and
10 | contributors .
11 |
12 |
13 |
14 | ```sh
15 | npx create-epic-app@latest
16 | ```
17 |
18 | [](https://www.epicweb.dev/epic-stack)
19 |
20 | [The Epic Stack](https://www.epicweb.dev/epic-stack)
21 |
22 |
23 |
24 | ## Watch Kent's Introduction to The Epic Stack
25 |
26 | [](https://www.epicweb.dev/talks/the-epic-stack)
27 |
28 | ["The Epic Stack" by Kent C. Dodds](https://www.epicweb.dev/talks/the-epic-stack)
29 |
30 | ## Docs
31 |
32 | [Read the docs](https://github.com/epicweb-dev/epic-stack/blob/main/docs)
33 | (please 🙏).
34 |
35 | ## Support
36 |
37 | - 🆘 Join the
38 | [discussion on GitHub](https://github.com/epicweb-dev/epic-stack/discussions)
39 | and the [KCD Community on Discord](https://kcd.im/discord).
40 | - 💡 Create an
41 | [idea discussion](https://github.com/epicweb-dev/epic-stack/discussions/new?category=ideas)
42 | for suggestions.
43 | - 🐛 Open a [GitHub issue](https://github.com/epicweb-dev/epic-stack/issues) to
44 | report a bug.
45 |
46 | ## Branding
47 |
48 | Want to talk about the Epic Stack in a blog post or talk? Great! Here are some
49 | assets you can use in your material:
50 | [EpicWeb.dev/brand](https://epicweb.dev/brand)
51 |
52 | ## Thanks
53 |
54 | You rock 🪨
55 |
--------------------------------------------------------------------------------
/app/components/error-boundary.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type ErrorResponse,
3 | isRouteErrorResponse,
4 | useParams,
5 | useRouteError,
6 | } from '@remix-run/react'
7 | import { captureRemixErrorBoundaryError } from '@sentry/remix'
8 | import { getErrorMessage } from '#app/utils/misc.tsx'
9 |
10 | type StatusHandler = (info: {
11 | error: ErrorResponse
12 | params: Record
13 | }) => JSX.Element | null
14 |
15 | export function GeneralErrorBoundary({
16 | defaultStatusHandler = ({ error }) => (
17 |
18 | {error.status} {error.data}
19 |
20 | ),
21 | statusHandlers,
22 | unexpectedErrorHandler = error => {getErrorMessage(error)}
,
23 | }: {
24 | defaultStatusHandler?: StatusHandler
25 | statusHandlers?: Record
26 | unexpectedErrorHandler?: (error: unknown) => JSX.Element | null
27 | }) {
28 | const error = useRouteError()
29 | captureRemixErrorBoundaryError(error)
30 | const params = useParams()
31 |
32 | if (typeof document !== 'undefined') {
33 | console.error(error)
34 | }
35 |
36 | return (
37 |
38 | {isRouteErrorResponse(error)
39 | ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({
40 | error,
41 | params,
42 | })
43 | : unexpectedErrorHandler(error)}
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/app/components/floating-toolbar.tsx:
--------------------------------------------------------------------------------
1 | export const floatingToolbarClassName =
2 | 'absolute bottom-3 left-3 right-3 flex items-center gap-2 rounded-lg bg-muted/80 p-4 pl-5 shadow-xl shadow-accent backdrop-blur-sm md:gap-4 md:pl-7 justify-end'
3 |
--------------------------------------------------------------------------------
/app/components/progress-bar.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigation } from '@remix-run/react'
2 | import { useEffect, useRef, useState } from 'react'
3 | import { useSpinDelay } from 'spin-delay'
4 | import { cn } from '#app/utils/misc.tsx'
5 | import { Icon } from './ui/icon.tsx'
6 |
7 | function EpicProgress() {
8 | const transition = useNavigation()
9 | const busy = transition.state !== 'idle'
10 | const delayedPending = useSpinDelay(busy, {
11 | delay: 600,
12 | minDuration: 400,
13 | })
14 | const ref = useRef(null)
15 | const [animationComplete, setAnimationComplete] = useState(true)
16 |
17 | useEffect(() => {
18 | if (!ref.current) return
19 | if (delayedPending) setAnimationComplete(false)
20 |
21 | const animationPromises = ref.current
22 | .getAnimations()
23 | .map(({ finished }) => finished)
24 |
25 | Promise.allSettled(animationPromises).then(() => {
26 | if (!delayedPending) setAnimationComplete(true)
27 | })
28 | }, [delayedPending])
29 |
30 | return (
31 |
37 |
49 | {delayedPending && (
50 |
51 |
57 |
58 | )}
59 |
60 | )
61 | }
62 |
63 | export { EpicProgress }
64 |
--------------------------------------------------------------------------------
/app/components/search-bar.tsx:
--------------------------------------------------------------------------------
1 | import { Form, useSearchParams, useSubmit } from '@remix-run/react'
2 | import { useId } from 'react'
3 | import { useDebounce, useIsPending } from '#app/utils/misc.tsx'
4 | import { Icon } from './ui/icon.tsx'
5 | import { Input } from './ui/input.tsx'
6 | import { Label } from './ui/label.tsx'
7 | import { StatusButton } from './ui/status-button.tsx'
8 |
9 | export function SearchBar({
10 | status,
11 | autoFocus = false,
12 | autoSubmit = false,
13 | }: {
14 | status: 'idle' | 'pending' | 'success' | 'error'
15 | autoFocus?: boolean
16 | autoSubmit?: boolean
17 | }) {
18 | const id = useId()
19 | const [searchParams] = useSearchParams()
20 | const submit = useSubmit()
21 | const isSubmitting = useIsPending({
22 | formMethod: 'GET',
23 | formAction: '/users',
24 | })
25 |
26 | const handleFormChange = useDebounce((form: HTMLFormElement) => {
27 | submit(form)
28 | }, 400)
29 |
30 | return (
31 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/app/components/spacer.tsx:
--------------------------------------------------------------------------------
1 | export function Spacer({
2 | size,
3 | }: {
4 | /**
5 | * The size of the space
6 | *
7 | * 4xs: h-4 (16px)
8 | *
9 | * 3xs: h-8 (32px)
10 | *
11 | * 2xs: h-12 (48px)
12 | *
13 | * xs: h-16 (64px)
14 | *
15 | * sm: h-20 (80px)
16 | *
17 | * md: h-24 (96px)
18 | *
19 | * lg: h-28 (112px)
20 | *
21 | * xl: h-32 (128px)
22 | *
23 | * 2xl: h-36 (144px)
24 | *
25 | * 3xl: h-40 (160px)
26 | *
27 | * 4xl: h-44 (176px)
28 | */
29 | size:
30 | | '4xs'
31 | | '3xs'
32 | | '2xs'
33 | | 'xs'
34 | | 'sm'
35 | | 'md'
36 | | 'lg'
37 | | 'xl'
38 | | '2xl'
39 | | '3xl'
40 | | '4xl'
41 | }) {
42 | const options: Record = {
43 | '4xs': 'h-4',
44 | '3xs': 'h-8',
45 | '2xs': 'h-12',
46 | xs: 'h-16',
47 | sm: 'h-20',
48 | md: 'h-24',
49 | lg: 'h-28',
50 | xl: 'h-32',
51 | '2xl': 'h-36',
52 | '3xl': 'h-40',
53 | '4xl': 'h-44',
54 | }
55 | const className = options[size]
56 | return
57 | }
58 |
--------------------------------------------------------------------------------
/app/components/toaster.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { toast as showToast } from 'sonner'
3 | import { type Toast } from '#app/utils/toast.server.ts'
4 |
5 | export function useToast(toast?: Toast | null) {
6 | useEffect(() => {
7 | if (toast) {
8 | setTimeout(() => {
9 | showToast[toast.type](toast.title, {
10 | id: toast.id,
11 | description: toast.description,
12 | })
13 | }, 0)
14 | }
15 | }, [toast])
16 | }
17 |
--------------------------------------------------------------------------------
/app/components/ui/README.md:
--------------------------------------------------------------------------------
1 | # shadcn/ui
2 |
3 | Some components in this directory are downloaded via the
4 | [shadcn/ui](https://ui.shadcn.com) [CLI](https://ui.shadcn.com/docs/cli). Feel
5 | free to customize them to your needs. It's important to know that shadcn/ui is
6 | not a library of components you install, but instead it's a registry of prebuilt
7 | components which you can download and customize.
8 |
--------------------------------------------------------------------------------
/app/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from '@radix-ui/react-slot'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 | import * as React from 'react'
4 |
5 | import { cn } from '#app/utils/misc.tsx'
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors outline-none focus-visible:ring-4 focus-within:ring-4 ring-ring ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/80',
13 | destructive:
14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/80',
15 | outline:
16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
17 | secondary:
18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
19 | ghost: 'hover:bg-accent hover:text-accent-foreground',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | },
22 | size: {
23 | default: 'h-10 px-4 py-2',
24 | wide: 'px-24 py-5',
25 | sm: 'h-9 rounded-md px-3',
26 | lg: 'h-11 rounded-md px-8',
27 | pill: 'px-12 py-3 leading-3',
28 | icon: 'h-10 w-10',
29 | },
30 | },
31 | defaultVariants: {
32 | variant: 'default',
33 | size: 'default',
34 | },
35 | },
36 | )
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : 'button'
47 | return (
48 |
53 | )
54 | },
55 | )
56 | Button.displayName = 'Button'
57 |
58 | export { Button, buttonVariants }
59 |
--------------------------------------------------------------------------------
/app/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
2 | import * as React from 'react'
3 |
4 | import { cn } from '#app/utils/misc.tsx'
5 |
6 | export type CheckboxProps = Omit<
7 | React.ComponentPropsWithoutRef,
8 | 'type'
9 | > & {
10 | type?: string
11 | }
12 |
13 | const Checkbox = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 |
28 |
29 |
35 |
36 |
37 |
38 | ))
39 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
40 |
41 | export { Checkbox }
42 |
--------------------------------------------------------------------------------
/app/components/ui/icon.tsx:
--------------------------------------------------------------------------------
1 | import { type SVGProps } from 'react'
2 | import { cn } from '#app/utils/misc.tsx'
3 | import { type IconName } from '@/icon-name'
4 | import href from './icons/sprite.svg'
5 |
6 | export { href }
7 | export { IconName }
8 |
9 | const sizeClassName = {
10 | font: 'w-[1em] h-[1em]',
11 | xs: 'w-3 h-3',
12 | sm: 'w-4 h-4',
13 | md: 'w-5 h-5',
14 | lg: 'w-6 h-6',
15 | xl: 'w-7 h-7',
16 | } as const
17 |
18 | type Size = keyof typeof sizeClassName
19 |
20 | const childrenSizeClassName = {
21 | font: 'gap-1.5',
22 | xs: 'gap-1.5',
23 | sm: 'gap-1.5',
24 | md: 'gap-2',
25 | lg: 'gap-2',
26 | xl: 'gap-3',
27 | } satisfies Record
28 |
29 | /**
30 | * Renders an SVG icon. The icon defaults to the size of the font. To make it
31 | * align vertically with neighboring text, you can pass the text as a child of
32 | * the icon and it will be automatically aligned.
33 | * Alternatively, if you're not ok with the icon being to the left of the text,
34 | * you need to wrap the icon and text in a common parent and set the parent to
35 | * display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
36 | */
37 | export function Icon({
38 | name,
39 | size = 'font',
40 | className,
41 | children,
42 | ...props
43 | }: SVGProps & {
44 | name: IconName
45 | size?: Size
46 | }) {
47 | if (children) {
48 | return (
49 |
52 |
53 | {children}
54 |
55 | )
56 | }
57 | return (
58 |
62 |
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/app/components/ui/icons/README.md:
--------------------------------------------------------------------------------
1 | # Icons
2 |
3 | This directory contains SVG icons that are used by the app.
4 |
5 | Everything in this directory is generated by `npm run build:icons`.
6 |
--------------------------------------------------------------------------------
/app/components/ui/icons/name.d.ts:
--------------------------------------------------------------------------------
1 | // This file is generated by npm run build:icons
2 |
3 | export type IconName =
4 | | 'arrow-left'
5 | | 'arrow-right'
6 | | 'avatar'
7 | | 'camera'
8 | | 'check'
9 | | 'clock'
10 | | 'cross-1'
11 | | 'dots-horizontal'
12 | | 'download'
13 | | 'envelope-closed'
14 | | 'exit'
15 | | 'file-text'
16 | | 'github-logo'
17 | | 'laptop'
18 | | 'link-2'
19 | | 'lock-closed'
20 | | 'lock-open-1'
21 | | 'magnifying-glass'
22 | | 'moon'
23 | | 'pencil-1'
24 | | 'pencil-2'
25 | | 'plus'
26 | | 'question-mark-circled'
27 | | 'reset'
28 | | 'sun'
29 | | 'trash'
30 | | 'update'
31 |
--------------------------------------------------------------------------------
/app/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '#app/utils/misc.tsx'
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | },
22 | )
23 | Input.displayName = 'Input'
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/app/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from '@radix-ui/react-label'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 | import * as React from 'react'
4 |
5 | import { cn } from '#app/utils/misc.tsx'
6 |
7 | const labelVariants = cva(
8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/app/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster as Sonner } from 'sonner'
2 |
3 | type ToasterProps = React.ComponentProps
4 |
5 | const EpicToaster = ({ theme, ...props }: ToasterProps) => {
6 | return (
7 |
23 | )
24 | }
25 |
26 | export { EpicToaster }
27 |
--------------------------------------------------------------------------------
/app/components/ui/status-button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { useSpinDelay } from 'spin-delay'
3 | import { cn } from '#app/utils/misc.tsx'
4 | import { Button, type ButtonProps } from './button.tsx'
5 | import { Icon } from './icon.tsx'
6 | import {
7 | Tooltip,
8 | TooltipContent,
9 | TooltipProvider,
10 | TooltipTrigger,
11 | } from './tooltip.tsx'
12 |
13 | export const StatusButton = React.forwardRef<
14 | HTMLButtonElement,
15 | ButtonProps & {
16 | status: 'pending' | 'success' | 'error' | 'idle'
17 | message?: string | null
18 | spinDelay?: Parameters[1]
19 | }
20 | >(({ message, status, className, children, spinDelay, ...props }, ref) => {
21 | const delayedPending = useSpinDelay(status === 'pending', {
22 | delay: 400,
23 | minDuration: 300,
24 | ...spinDelay,
25 | })
26 | const companion = {
27 | pending: delayedPending ? (
28 |
29 |
30 |
31 | ) : null,
32 | success: (
33 |
34 |
35 |
36 | ),
37 | error: (
38 |
39 |
40 |
41 | ),
42 | idle: null,
43 | }[status]
44 |
45 | return (
46 |
51 | {children}
52 | {message ? (
53 |
54 |
55 | {companion}
56 | {message}
57 |
58 |
59 | ) : (
60 | companion
61 | )}
62 |
63 | )
64 | })
65 | StatusButton.displayName = 'Button'
66 |
--------------------------------------------------------------------------------
/app/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '#app/utils/misc.tsx'
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | },
21 | )
22 | Textarea.displayName = 'Textarea'
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/app/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
2 | import * as React from 'react'
3 |
4 | import { cn } from '#app/utils/misc.tsx'
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ))
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
29 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { RemixBrowser } from '@remix-run/react'
2 | import { startTransition } from 'react'
3 | import { hydrateRoot } from 'react-dom/client'
4 |
5 | if (ENV.MODE === 'production' && ENV.SENTRY_DSN) {
6 | import('./utils/monitoring.client.tsx').then(({ init }) => init())
7 | }
8 |
9 | startTransition(() => {
10 | hydrateRoot(document, )
11 | })
12 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { PassThrough } from 'stream'
2 | import {
3 | createReadableStreamFromReadable,
4 | type DataFunctionArgs,
5 | type HandleDocumentRequestFunction,
6 | } from '@remix-run/node'
7 | import { RemixServer } from '@remix-run/react'
8 | import * as Sentry from '@sentry/remix'
9 | import isbot from 'isbot'
10 | import { getInstanceInfo } from 'litefs-js'
11 | import { renderToPipeableStream } from 'react-dom/server'
12 | import { getEnv, init } from './utils/env.server.ts'
13 | import { NonceProvider } from './utils/nonce-provider.ts'
14 | import { makeTimings } from './utils/timing.server.ts'
15 |
16 | const ABORT_DELAY = 5000
17 |
18 | init()
19 | global.ENV = getEnv()
20 |
21 | if (ENV.MODE === 'production' && ENV.SENTRY_DSN) {
22 | import('./utils/monitoring.server.ts').then(({ init }) => init())
23 | }
24 |
25 | type DocRequestArgs = Parameters
26 |
27 | export default async function handleRequest(...args: DocRequestArgs) {
28 | const [
29 | request,
30 | responseStatusCode,
31 | responseHeaders,
32 | remixContext,
33 | loadContext,
34 | ] = args
35 | const { currentInstance, primaryInstance } = await getInstanceInfo()
36 | responseHeaders.set('fly-region', process.env.FLY_REGION ?? 'unknown')
37 | responseHeaders.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
38 | responseHeaders.set('fly-primary-instance', primaryInstance)
39 | responseHeaders.set('fly-instance', currentInstance)
40 |
41 | const callbackName = isbot(request.headers.get('user-agent'))
42 | ? 'onAllReady'
43 | : 'onShellReady'
44 |
45 | const nonce = String(loadContext.cspNonce) ?? undefined
46 | return new Promise(async (resolve, reject) => {
47 | let didError = false
48 | // NOTE: this timing will only include things that are rendered in the shell
49 | // and will not include suspended components and deferred loaders
50 | const timings = makeTimings('render', 'renderToPipeableStream')
51 |
52 | const { pipe, abort } = renderToPipeableStream(
53 |
54 |
55 | ,
56 | {
57 | [callbackName]: () => {
58 | const body = new PassThrough()
59 | responseHeaders.set('Content-Type', 'text/html')
60 | responseHeaders.append('Server-Timing', timings.toString())
61 | resolve(
62 | new Response(createReadableStreamFromReadable(body), {
63 | headers: responseHeaders,
64 | status: didError ? 500 : responseStatusCode,
65 | }),
66 | )
67 | pipe(body)
68 | },
69 | onShellError: (err: unknown) => {
70 | reject(err)
71 | },
72 | onError: (error: unknown) => {
73 | didError = true
74 |
75 | console.error(error)
76 | },
77 | nonce,
78 | },
79 | )
80 |
81 | setTimeout(abort, ABORT_DELAY)
82 | })
83 | }
84 |
85 | export async function handleDataRequest(response: Response) {
86 | const { currentInstance, primaryInstance } = await getInstanceInfo()
87 | response.headers.set('fly-region', process.env.FLY_REGION ?? 'unknown')
88 | response.headers.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
89 | response.headers.set('fly-primary-instance', primaryInstance)
90 | response.headers.set('fly-instance', currentInstance)
91 |
92 | return response
93 | }
94 |
95 | export function handleError(
96 | error: unknown,
97 | { request }: DataFunctionArgs,
98 | ): void {
99 | if (error instanceof Error) {
100 | Sentry.captureRemixServerException(error, 'remix.server', request)
101 | } else {
102 | Sentry.captureException(error)
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/app/routes/$.tsx:
--------------------------------------------------------------------------------
1 | // This is called a "splat route" and as it's in the root `/app/routes/`
2 | // directory, it's a catchall. If no other routes match, this one will and we
3 | // can know that the user is hitting a URL that doesn't exist. By throwing a
4 | // 404 from the loader, we can force the error boundary to render which will
5 | // ensure the user gets the right status code and we can display a nicer error
6 | // message for them than the Remix and/or browser default.
7 |
8 | import { Link, useLocation } from '@remix-run/react'
9 | import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
10 | import { Icon } from '#app/components/ui/icon.tsx'
11 |
12 | export async function loader() {
13 | throw new Response('Not found', { status: 404 })
14 | }
15 |
16 | export default function NotFound() {
17 | // due to the loader, this component will never be rendered, but we'll return
18 | // the error boundary just in case.
19 | return
20 | }
21 |
22 | export function ErrorBoundary() {
23 | const location = useLocation()
24 | return (
25 | (
28 |
29 |
30 |
We can't find this page:
31 |
32 | {location.pathname}
33 |
34 |
35 |
36 |
Back to home
37 |
38 |
39 | ),
40 | }}
41 | />
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/app/routes/_marketing+/get-routes.server.ts:
--------------------------------------------------------------------------------
1 | import { getRoutes as globRoutes } from '#app/utils/get-routes.server'
2 |
3 | type Route = {
4 | name: string
5 | path: string
6 | }
7 |
8 | type Routes = {
9 | tips: Route[]
10 | tutorials: Route[]
11 | }
12 |
13 | export const getRoutes: () => Promise = async () => {
14 | const tipRoutes = await globRoutes('app/routes/tips+/*.tsx')
15 | const tutorialRoutes = await globRoutes('app/routes/tutorials+/*/*.tsx')
16 | const tips = tipRoutes
17 | .map(route => ({
18 | name: route
19 | .replace('app/routes/tips+/', '')
20 | .replace('.tsx', '')
21 | .replace(/-/g, ' '),
22 | path: route.replace('app/routes/tips+', '/tips').replace('.tsx', ''),
23 | }))
24 | .sort((a, b) => a.name.localeCompare(b.name))
25 |
26 | const tutorials = tutorialRoutes
27 | .map(route => ({
28 | name: route
29 | .replace('app/routes/tutorials+/', '')
30 | .replace('+/index.tsx', '')
31 | .replace(/-/g, ' '),
32 | path: route
33 | .replace('app/routes/tutorials+', '/tutorials')
34 | .replace('+', '')
35 | .replace('index.tsx', ''),
36 | }))
37 | .sort((a, b) => a.name.localeCompare(b.name))
38 | return { tips, tutorials }
39 | }
40 |
--------------------------------------------------------------------------------
/app/routes/_marketing+/index.tsx:
--------------------------------------------------------------------------------
1 | import { json, type MetaFunction } from '@remix-run/node'
2 | import { Link, useLoaderData } from '@remix-run/react'
3 | import { getRoutes } from './get-routes.server'
4 |
5 | export const loader = async () => {
6 | const { tips, tutorials } = await getRoutes()
7 | if (!tips) throw new Error('Tips not found')
8 | if (!tutorials) throw new Error('UI Challenges not found')
9 | return json({ tips, tutorials })
10 | }
11 |
12 | export const meta: MetaFunction = () => [{ title: 'Epic CSS Tips' }]
13 |
14 | export default function Index() {
15 | const { tips, tutorials } = useLoaderData()
16 | return (
17 |
18 |
19 |
Epic CSS Tips
20 |
21 |
22 | {tips.length > 0 && (
23 |
24 | Tips
25 |
26 | {tips.map(tip => (
27 |
28 |
29 | {tip.name}
30 |
31 |
32 | ))}
33 |
34 |
35 | )}
36 |
37 | {tutorials.length > 0 && (
38 |
39 | Tutorials
40 |
41 | {tutorials.map(tutorial => (
42 |
43 |
47 | {tutorial.name}
48 |
49 |
50 | ))}
51 |
52 |
53 | )}
54 |
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/app/routes/_marketing+/privacy.tsx:
--------------------------------------------------------------------------------
1 | export default function PrivacyRoute() {
2 | return Privacy
3 | }
4 |
--------------------------------------------------------------------------------
/app/routes/_marketing+/tos.tsx:
--------------------------------------------------------------------------------
1 | export default function TermsOfServiceRoute() {
2 | return Terms of service
3 | }
4 |
--------------------------------------------------------------------------------
/app/routes/_seo+/robots[.]txt.ts:
--------------------------------------------------------------------------------
1 | import { generateRobotsTxt } from '@nasa-gcn/remix-seo'
2 | import { type DataFunctionArgs } from '@remix-run/node'
3 | import { getDomainUrl } from '#app/utils/misc.tsx'
4 |
5 | export function loader({ request }: DataFunctionArgs) {
6 | return generateRobotsTxt([
7 | { type: 'sitemap', value: `${getDomainUrl(request)}/sitemap.xml` },
8 | ])
9 | }
10 |
--------------------------------------------------------------------------------
/app/routes/_seo+/sitemap[.]xml.ts:
--------------------------------------------------------------------------------
1 | import { generateSitemap } from '@nasa-gcn/remix-seo'
2 | import { routes } from '@remix-run/dev/server-build'
3 | import { type DataFunctionArgs } from '@remix-run/node'
4 | import { getDomainUrl } from '#app/utils/misc.tsx'
5 |
6 | export function loader({ request }: DataFunctionArgs) {
7 | return generateSitemap(request, routes, {
8 | siteUrl: getDomainUrl(request),
9 | headers: {
10 | 'Cache-Control': `public, max-age=${60 * 5}`,
11 | },
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/app/routes/admin+/cache_.lru.$cacheKey.ts:
--------------------------------------------------------------------------------
1 | import { invariantResponse } from '@epic-web/invariant'
2 | import { json, type DataFunctionArgs } from '@remix-run/node'
3 | import { getAllInstances, getInstanceInfo } from 'litefs-js'
4 | import { ensureInstance } from 'litefs-js/remix.js'
5 | import { lruCache } from '#app/utils/cache.server.ts'
6 | import { requireUserWithRole } from '#app/utils/permissions.ts'
7 |
8 | export async function loader({ request, params }: DataFunctionArgs) {
9 | await requireUserWithRole(request, 'admin')
10 | const searchParams = new URL(request.url).searchParams
11 | const currentInstanceInfo = await getInstanceInfo()
12 | const allInstances = await getAllInstances()
13 | const instance =
14 | searchParams.get('instance') ?? currentInstanceInfo.currentInstance
15 | await ensureInstance(instance)
16 |
17 | const { cacheKey } = params
18 | invariantResponse(cacheKey, 'cacheKey is required')
19 | return json({
20 | instance: {
21 | hostname: instance,
22 | region: allInstances[instance],
23 | isPrimary: currentInstanceInfo.primaryInstance === instance,
24 | },
25 | cacheKey,
26 | value: lruCache.get(cacheKey),
27 | })
28 | }
29 |
--------------------------------------------------------------------------------
/app/routes/admin+/cache_.sqlite.$cacheKey.ts:
--------------------------------------------------------------------------------
1 | import { invariantResponse } from '@epic-web/invariant'
2 | import { json, type DataFunctionArgs } from '@remix-run/node'
3 | import { getAllInstances, getInstanceInfo } from 'litefs-js'
4 | import { ensureInstance } from 'litefs-js/remix.js'
5 | import { cache } from '#app/utils/cache.server.ts'
6 | import { requireUserWithRole } from '#app/utils/permissions.ts'
7 |
8 | export async function loader({ request, params }: DataFunctionArgs) {
9 | await requireUserWithRole(request, 'admin')
10 | const searchParams = new URL(request.url).searchParams
11 | const currentInstanceInfo = await getInstanceInfo()
12 | const allInstances = await getAllInstances()
13 | const instance =
14 | searchParams.get('instance') ?? currentInstanceInfo.currentInstance
15 | await ensureInstance(instance)
16 |
17 | const { cacheKey } = params
18 | invariantResponse(cacheKey, 'cacheKey is required')
19 | return json({
20 | instance: {
21 | hostname: instance,
22 | region: allInstances[instance],
23 | isPrimary: currentInstanceInfo.primaryInstance === instance,
24 | },
25 | cacheKey,
26 | value: cache.get(cacheKey),
27 | })
28 | }
29 |
--------------------------------------------------------------------------------
/app/routes/admin+/cache_.sqlite.tsx:
--------------------------------------------------------------------------------
1 | import { type DataFunctionArgs, json, redirect } from '@remix-run/node'
2 | import { getInstanceInfo, getInternalInstanceDomain } from 'litefs-js'
3 | import { z } from 'zod'
4 | import { cache } from '#app/utils/cache.server.ts'
5 |
6 | export async function action({ request }: DataFunctionArgs) {
7 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo()
8 | if (!currentIsPrimary) {
9 | throw new Error(
10 | `${request.url} should only be called on the primary instance (${primaryInstance})}`,
11 | )
12 | }
13 | const token = process.env.INTERNAL_COMMAND_TOKEN
14 | const isAuthorized =
15 | request.headers.get('Authorization') === `Bearer ${token}`
16 | if (!isAuthorized) {
17 | // nah, you can't be here...
18 | return redirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
19 | }
20 | const { key, cacheValue } = z
21 | .object({ key: z.string(), cacheValue: z.unknown().optional() })
22 | .parse(await request.json())
23 | if (cacheValue === undefined) {
24 | await cache.delete(key)
25 | } else {
26 | // @ts-expect-error - we don't reliably know the type of cacheValue
27 | await cache.set(key, cacheValue)
28 | }
29 | return json({ success: true })
30 | }
31 |
32 | export async function updatePrimaryCacheValue({
33 | key,
34 | cacheValue,
35 | }: {
36 | key: string
37 | cacheValue: any
38 | }) {
39 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo()
40 | if (currentIsPrimary) {
41 | throw new Error(
42 | `updatePrimaryCacheValue should not be called on the primary instance (${primaryInstance})}`,
43 | )
44 | }
45 | const domain = getInternalInstanceDomain(primaryInstance)
46 | const token = process.env.INTERNAL_COMMAND_TOKEN
47 | return fetch(`${domain}/admin/cache/sqlite`, {
48 | method: 'POST',
49 | headers: {
50 | Authorization: `Bearer ${token}`,
51 | 'Content-Type': 'application/json',
52 | },
53 | body: JSON.stringify({ key, cacheValue }),
54 | })
55 | }
56 |
--------------------------------------------------------------------------------
/app/routes/tips+/background-clip-text.tsx:
--------------------------------------------------------------------------------
1 | export default function ImageText() {
2 | return (
3 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/app/routes/tips+/blend-modes.tsx:
--------------------------------------------------------------------------------
1 | // https://www.pinterest.ch/pin/186195765827267260/
2 |
3 | export default function BlendModes() {
4 | return (
5 |
6 |
13 |
14 |
22 |
23 |
33 |
34 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/app/routes/tips+/color-spreads.tsx:
--------------------------------------------------------------------------------
1 | import resolveConfig from 'tailwindcss/resolveConfig'
2 | import tailwindConfig from '../../../tailwind.config'
3 |
4 | const resolvedConfig = resolveConfig(tailwindConfig)
5 |
6 | const colorSpreads = Object.entries(resolvedConfig.theme.colors).filter(
7 | ([key]) =>
8 | !['inherit', 'current', 'transparent', 'black', 'white'].includes(key),
9 | )
10 |
11 | export default function ColorSpreads() {
12 | return (
13 |
14 |
15 | {colorSpreads.map(([spreadKey, colorGroup]) => (
16 |
20 |
21 | {spreadKey}
22 |
23 |
24 | {Object.entries(colorGroup).map(([colorKey, colorValue]) => {
25 | const parsedColorValue = parseInt(colorKey)
26 | const isNumberScale = !Number.isNaN(parsedColorValue)
27 |
28 | const color = isNumberScale
29 | ? parsedColorValue <= 500
30 | ? colorGroup['900']
31 | : colorGroup['300']
32 | : '#fff'
33 |
34 | return (
35 |
43 | {colorKey}
44 |
45 | )
46 | })}
47 |
48 |
49 | ))}
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/app/routes/tips+/direct-children.tsx:
--------------------------------------------------------------------------------
1 | export default function DirectChildren() {
2 | return (
3 |
4 |
Direct children selector
5 |
6 | you
7 | can
8 | now
9 | style
10 | direct
11 | children
12 | of
13 | elements
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/app/routes/tips+/drop-shadow.tsx:
--------------------------------------------------------------------------------
1 | export default function DropShadow() {
2 | return (
3 |
4 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/app/routes/tips+/glassmorphism.tsx:
--------------------------------------------------------------------------------
1 | export default function Glassmorphism() {
2 | return (
3 |
4 |
5 |
6 |
11 |
16 |
17 |
22 |
27 |
28 |
29 |
30 |
34 |
35 |
36 |
John Doe
37 |
05/26
38 |
39 |
0493 3382 1172 4502
40 |
41 |
42 |
43 |
44 |
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/app/routes/tips+/hamburger-menu.tsx:
--------------------------------------------------------------------------------
1 | export default function HamburgerMenu() {
2 | return (
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/app/routes/tips+/has.tsx:
--------------------------------------------------------------------------------
1 | const cardsData = [
2 | {
3 | title: 'Card Without An Image',
4 | text: 'Lorem epic dolor stack amet epic adipisicing elit.',
5 | },
6 | {
7 | text: "This card doesn't even have a title! No image, no title. Just a bunch of text. Still, this card belongs here!",
8 | },
9 | {
10 | title: 'Card with title and image',
11 | text: 'Lorem epic dolor stack amet consectetur.',
12 | image: `https://images.unsplash.com/photo-1530714457710-6bf1899c1d32?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjZ8fHZpYnJhbnQlMjBjb2xvcnN8ZW58MHwwfDB8fHww`,
13 | },
14 | {
15 | text: 'This card has an image, but no title.',
16 | image: `https://images.unsplash.com/photo-1528811692195-d5037ac4b7cc?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTA2fHx2aWJyYW50JTIwY29sb3JzfGVufDB8MHwwfHx8MA%3D%3D`,
17 | },
18 | ]
19 |
20 | export default function Has() {
21 | return (
22 |
23 |
24 | {cardsData.map((card, i) => (
25 |
26 | ))}
27 |
28 |
29 | )
30 | }
31 |
32 | // ------------------------------
33 | // Card
34 | // ------------------------------
35 |
36 | type CardProps = {
37 | image?: string
38 | title?: string
39 | text: string
40 | }
41 |
42 | function Card({ image, title, text }: CardProps) {
43 | return (
44 |
51 | {image && }
52 |
53 | {title &&
{title} }
54 |
{text}
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/app/routes/tips+/iphone-cards.tsx:
--------------------------------------------------------------------------------
1 | export default function App() {
2 | return (
3 |
4 |
5 |
10 | {/* notch */}
11 |
14 |
15 | {/* Content overlay */}
16 |
17 |
18 |
19 |
20 | Kody McWheel
21 |
22 |
23 | Member since July 2022
24 |
25 |
26 |
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/app/routes/tips+/mix-blend-mode-text-clip.tsx:
--------------------------------------------------------------------------------
1 | export default function ImageText() {
2 | return (
3 |
4 |
5 |
10 |
11 |
Epic Web
12 |
13 |
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/app/routes/tips+/mouse-fairy.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export default function MouseFairy() {
4 | const [mousePosition, setMousePOsition] = useState({ x: 0, y: 0 })
5 | const [dots, setDots] = useState<{ key: string; x: number; y: number }[]>([])
6 |
7 | function updateDots(e: any) {
8 | setMousePOsition({ x: e.clientX, y: e.clientY })
9 | const timestamp = Date.now()
10 | console.log({ timestamp })
11 | setDots(dots => [
12 | ...dots,
13 | { key: timestamp.toString(), x: e.clientX, y: e.clientY },
14 | ])
15 | setTimeout(() => {
16 | // Remove the dot that with the corresponding key
17 | setDots(dots => dots.filter(dot => dot.key !== timestamp.toString()))
18 | }, 1000)
19 | }
20 | useEffect(() => {
21 | const DURATION = 30000000000
22 | const throttle = (function () {
23 | let timeout: any = undefined
24 | return function throttle(callback: () => void) {
25 | if (timeout === undefined) {
26 | callback()
27 | timeout = setTimeout(() => {
28 | // allow another call to be throttled
29 | timeout = undefined
30 | }, DURATION)
31 | }
32 | }
33 | })()
34 | function throttlify(callback: (e: any) => void) {
35 | return function throttlified(event: any) {
36 | throttle(() => {
37 | callback(event)
38 | })
39 | }
40 | }
41 | document.addEventListener('mousemove', throttlify(updateDots))
42 | return () => {
43 | document.removeEventListener('mousemove', updateDots)
44 | }
45 | }, [mousePosition])
46 |
47 | return (
48 |
49 | {dots.map(dot => (
50 |
51 | ))}
52 |
53 | )
54 | }
55 |
56 | function Dot({ x, y }: { x: number; y: number }) {
57 | return (
58 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/app/routes/tips+/nested-absolute-positions.tsx:
--------------------------------------------------------------------------------
1 | export default function NestedAbsolutePositions() {
2 | const cardClasses =
3 | 'h-20 w-14 rounded-lg bg-white shadow-lg ring-1 ring-black/5 translate-x-1 -translate-y-1 grid place-items-center'
4 |
5 | const parentSquare = cardClasses + 'group'
6 |
7 | const childSquare =
8 | cardClasses +
9 | 'absolute group-hover:-translate-y-2 group-hover:translate-x-8 group-hover:rotate-12 transition'
10 |
11 | return (
12 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/app/routes/tips+/reduced-motion.tsx:
--------------------------------------------------------------------------------
1 | export default function ImageText() {
2 | return (
3 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/index.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 |
24 |
25 | The card title is here.
26 |
27 |
28 |
29 | Lorem ipsum dolor sit, amet consectetur adipisicing elit.
30 | Minima quia ipsa eius.
31 |
32 |
33 |
34 |
35 |
36 | ))}
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/01.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | return (
3 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/02.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | return (
3 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/03.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | return (
3 |
4 |
5 | {[...Array(4).keys()].map(item => (
6 |
10 | ))}
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/04.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 | ))}
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/05.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 |
24 | The card title is here.
25 |
26 |
27 | Lorem ipsum dolor sit, amet consectetur adipisicing elit. Minima
28 | quia ipsa eius.
29 |
30 |
31 |
32 | ))}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/06.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 |
24 | The card title is here.
25 |
26 |
27 | Lorem ipsum dolor sit, amet consectetur adipisicing elit. Minima
28 | quia ipsa eius.
29 |
30 |
31 |
32 | ))}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/07.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 |
24 | The card title is here.
25 |
26 |
27 | Lorem ipsum dolor sit, amet consectetur adipisicing elit. Minima
28 | quia ipsa eius.
29 |
30 |
31 |
32 | ))}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/08.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 |
24 | The card title is here.
25 |
26 |
27 |
28 | Lorem ipsum dolor sit, amet consectetur adipisicing elit.
29 | Minima quia ipsa eius.
30 |
31 |
32 |
33 |
34 | ))}
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/09.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 |
24 | The card title is here.
25 |
26 |
27 |
28 | Lorem ipsum dolor sit, amet consectetur adipisicing elit.
29 | Minima quia ipsa eius.
30 |
31 |
32 |
33 |
34 | ))}
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/10.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 |
24 | The card title is here.
25 |
26 |
27 |
28 | Lorem ipsum dolor sit, amet consectetur adipisicing elit.
29 | Minima quia ipsa eius.
30 |
31 |
32 |
33 |
34 | ))}
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/11.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | const imageIds = [
3 | '1500462918059-b1a0cb512f1d',
4 | '1531581147762-5961e6e2e6b1',
5 | '1626204327506-0d3ee11d7752',
6 | '1549068106-b024baf5062d',
7 | ]
8 |
9 | return (
10 |
11 |
12 | {[...Array(4).keys()].map((_, index) => (
13 |
17 |
22 |
23 |
24 |
25 | The card title is here.
26 |
27 |
28 |
29 | Lorem ipsum dolor sit, amet consectetur adipisicing elit.
30 | Minima quia ipsa eius.
31 |
32 |
33 |
34 |
35 |
36 | ))}
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps+/index.tsx:
--------------------------------------------------------------------------------
1 | import { json } from '@remix-run/node'
2 | import { Link, useLoaderData } from '@remix-run/react'
3 | import { formatStep } from '#app/utils/format-step'
4 | import { getRoutes } from '#app/utils/get-routes.server'
5 |
6 | export async function loader() {
7 | const routes = await getRoutes(
8 | 'app/routes/tutorials+/fluid-hover-cards+/steps+/[!index]*.tsx',
9 | )
10 |
11 | return json({ routes })
12 | }
13 |
14 | export default function Index() {
15 | const { routes } = useLoaderData()
16 | return (
17 |
18 |
19 |
Steps breakdown
20 |
21 | {routes.length > 0 && (
22 |
23 | {routes.map((route, index) => {
24 | const step = formatStep(index + 1)
25 | return (
26 |
27 |
28 | Step {step}
29 |
30 |
31 | )
32 | })}
33 |
34 | )}
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/fluid-hover-cards+/steps/01.tsx:
--------------------------------------------------------------------------------
1 | export default function HoverStretchCards() {
2 | return (
3 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/app/routes/tutorials+/gsap-rockets.tsx:
--------------------------------------------------------------------------------
1 | import { gsap } from 'gsap'
2 | import { ScrollTrigger } from 'gsap/dist/ScrollTrigger'
3 | import { useEffect } from 'react'
4 | import defaultTheme from 'tailwindcss/defaultTheme'
5 |
6 | export default function Rockets() {
7 | useEffect(() => {
8 | gsap.registerPlugin(ScrollTrigger)
9 |
10 | gsap.to('[data-gsap-thing]', {
11 | scrollTrigger: {
12 | trigger: '[data-gsap-thing]',
13 | start: 'top 50%',
14 | end: 'bottom 20%',
15 | scrub: 0.5,
16 | },
17 | y: defaultTheme.spacing[32],
18 | })
19 | gsap.to('[data-gsap-thing-two]', {
20 | scrollTrigger: {
21 | trigger: '[data-gsap-thing-two]',
22 | start: 'top 50%',
23 | end: 'bottom 20%',
24 | scrub: 3,
25 | markers: true,
26 | },
27 | y: -64,
28 | })
29 | }, [])
30 |
31 | return (
32 | <>
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
72 |
73 |
74 | We have only the best rocket brands!
75 |
76 |
77 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Vel ut
78 | nesciunt, exercitationem ullam eveniet quae unde excepturi sit
79 | sunt animi distinctio consequatur. Voluptatibus quidem consequatur
80 | perferendis doloremque laboriosam repudiandae sit?
81 |
82 |
86 | Explore rockets
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | >
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/app/styles/tailwind.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Paytone+One&display=swap');
2 |
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | @layer base {
8 | :root {
9 | /* --font-sans: here if you have one */
10 | /* --font-mono: here if you got it... */
11 |
12 | /* prefixed with foreground because it should look good on the background */
13 | --foreground-destructive: 345 82.7% 40.8%;
14 |
15 | --background: 0 0% 100%;
16 | --foreground: 222.2 84% 4.9%;
17 |
18 | --muted: 210 40% 93%;
19 | --muted-foreground: 215.4 16.3% 30%;
20 |
21 | --popover: 0 0% 100%;
22 | --popover-foreground: 222.2 84% 4.9%;
23 |
24 | --card: 0 0% 100%;
25 | --card-foreground: 222.2 84% 4.9%;
26 |
27 | --border: 214.3 31.8% 91.4%;
28 | --input: 214.3 31.8% 91.4%;
29 | --input-invalid: 0 84.2% 60.2%;
30 |
31 | --primary: 222.2 47.4% 11.2%;
32 | --primary-foreground: 210 40% 98%;
33 |
34 | --secondary: 210 20% 83%;
35 | --secondary-foreground: 222.2 47.4% 11.2%;
36 |
37 | --accent: 210 40% 90%;
38 | --accent-foreground: 222.2 47.4% 11.2%;
39 |
40 | --destructive: 0 70% 50%;
41 | --destructive-foreground: 210 40% 98%;
42 |
43 | --ring: 215 20.2% 65.1%;
44 |
45 | --radius: 0.5rem;
46 | }
47 |
48 | .dark {
49 | --background: 222.2 84% 4.9%;
50 | --foreground: 210 40% 98%;
51 |
52 | /* prefixed with foreground because it should look good on the background */
53 | --foreground-destructive: -4 84% 60%;
54 |
55 | --muted: 217.2 32.6% 12%;
56 | --muted-foreground: 215 20.2% 65.1%;
57 |
58 | --popover: 222.2 84% 4.9%;
59 | --popover-foreground: 210 40% 98%;
60 |
61 | --card: 222.2 84% 4.9%;
62 | --card-foreground: 210 40% 98%;
63 |
64 | --border: 217.2 32.6% 17.5%;
65 | --input: 217.2 32.6% 17.5%;
66 | --input-invalid: 0 62.8% 30.6%;
67 |
68 | --primary: 210 40% 98%;
69 | --primary-foreground: 222.2 47.4% 11.2%;
70 |
71 | --secondary: 217.2 20% 24%;
72 | --secondary-foreground: 210 40% 98%;
73 |
74 | --accent: 217.2 32.6% 10%;
75 | --accent-foreground: 210 40% 98%;
76 |
77 | --destructive: 0 60% 40%;
78 | --destructive-foreground: 0 85.7% 97.3%;
79 |
80 | --ring: 217.2 32.6% 60%;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/tailwind-presets/image-text.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from 'tailwindcss'
2 |
3 | export default {
4 | content: [],
5 | theme: {
6 | extend: {
7 | fontFamily: {
8 | 'paytone-one': ['Paytone One', 'sans-serif'],
9 | },
10 | keyframes: {
11 | 'slowpan-2': {
12 | '0%': { transform: 'translateX(0) translateY(0)' },
13 | '100%': {
14 | transform: `translateX(-${(20 / 1.2).toFixed(2)}%) translateY(-${(
15 | 20 / 1.2
16 | ).toFixed(2)}%)`,
17 | },
18 | },
19 | slowpan: {
20 | '0%': { backgroundPosition: 'top left' },
21 | '100%': { backgroundPosition: 'bottom right' },
22 | },
23 | },
24 | animation: {
25 | slowpan: 'slowpan 15s alternate ease-in-out infinite',
26 | 'slowpan-2': 'slowpan-2 15s alternate ease-in-out infinite',
27 | },
28 | },
29 | },
30 | } satisfies Config
31 |
--------------------------------------------------------------------------------
/app/utils/client-hints.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This file contains utilities for using client hints for user preference which
3 | * are needed by the server, but are only known by the browser.
4 | */
5 | import { getHintUtils } from '@epic-web/client-hints'
6 | import {
7 | clientHint as colorSchemeHint,
8 | subscribeToSchemeChange,
9 | } from '@epic-web/client-hints/color-scheme'
10 | import { clientHint as timeZoneHint } from '@epic-web/client-hints/time-zone'
11 | import { useRevalidator } from '@remix-run/react'
12 | import * as React from 'react'
13 | import { useRequestInfo } from './request-info.ts'
14 |
15 | const hintsUtils = getHintUtils({
16 | theme: colorSchemeHint,
17 | timeZone: timeZoneHint,
18 | // add other hints here
19 | })
20 |
21 | export const { getHints } = hintsUtils
22 |
23 | /**
24 | * @returns an object with the client hints and their values
25 | */
26 | export function useHints() {
27 | const requestInfo = useRequestInfo()
28 | return requestInfo.hints
29 | }
30 |
31 | /**
32 | * @returns inline script element that checks for client hints and sets cookies
33 | * if they are not set then reloads the page if any cookie was set to an
34 | * inaccurate value.
35 | */
36 | export function ClientHintCheck({ nonce }: { nonce: string }) {
37 | const { revalidate } = useRevalidator()
38 | React.useEffect(
39 | () => subscribeToSchemeChange(() => revalidate()),
40 | [revalidate],
41 | )
42 |
43 | return (
44 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/utils/connections.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from '@remix-run/node'
2 | import { type ProviderName } from './connections.tsx'
3 | import { GitHubProvider } from './providers/github.server.ts'
4 | import { type AuthProvider } from './providers/provider.ts'
5 | import { type Timings } from './timing.server.ts'
6 |
7 | export const connectionSessionStorage = createCookieSessionStorage({
8 | cookie: {
9 | name: 'en_connection',
10 | sameSite: 'lax',
11 | path: '/',
12 | httpOnly: true,
13 | maxAge: 60 * 10, // 10 minutes
14 | secrets: process.env.SESSION_SECRET.split(','),
15 | secure: process.env.NODE_ENV === 'production',
16 | },
17 | })
18 |
19 | export const providers: Record = {
20 | github: new GitHubProvider(),
21 | }
22 |
23 | export function handleMockAction(providerName: ProviderName, request: Request) {
24 | return providers[providerName].handleMockAction(request)
25 | }
26 |
27 | export function resolveConnectionData(
28 | providerName: ProviderName,
29 | providerId: string,
30 | options?: { timings?: Timings },
31 | ) {
32 | return providers[providerName].resolveConnectionData(providerId, options)
33 | }
34 |
--------------------------------------------------------------------------------
/app/utils/connections.tsx:
--------------------------------------------------------------------------------
1 | import { Form } from '@remix-run/react'
2 | import { z } from 'zod'
3 | import { Icon } from '#app/components/ui/icon.tsx'
4 | import { StatusButton } from '#app/components/ui/status-button.tsx'
5 | import { useIsPending } from './misc.tsx'
6 |
7 | export const GITHUB_PROVIDER_NAME = 'github'
8 | // to add another provider, set their name here and add it to the providerNames below
9 |
10 | export const providerNames = [GITHUB_PROVIDER_NAME] as const
11 | export const ProviderNameSchema = z.enum(providerNames)
12 | export type ProviderName = z.infer
13 |
14 | export const providerLabels: Record = {
15 | [GITHUB_PROVIDER_NAME]: 'GitHub',
16 | } as const
17 |
18 | export const providerIcons: Record = {
19 | [GITHUB_PROVIDER_NAME]: ,
20 | } as const
21 |
22 | export function ProviderConnectionForm({
23 | redirectTo,
24 | type,
25 | providerName,
26 | }: {
27 | redirectTo?: string | null
28 | type: 'Connect' | 'Login' | 'Signup'
29 | providerName: ProviderName
30 | }) {
31 | const label = providerLabels[providerName]
32 | const formAction = `/auth/${providerName}`
33 | const isPending = useIsPending({ formAction })
34 | return (
35 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/app/utils/csrf.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookie } from '@remix-run/node'
2 | import { CSRF, CSRFError } from 'remix-utils/csrf/server'
3 |
4 | const cookie = createCookie('csrf', {
5 | path: '/',
6 | httpOnly: true,
7 | secure: process.env.NODE_ENV === 'production',
8 | sameSite: 'lax',
9 | secrets: process.env.SESSION_SECRET.split(','),
10 | })
11 |
12 | export const csrf = new CSRF({ cookie })
13 |
14 | export async function validateCSRF(formData: FormData, headers: Headers) {
15 | try {
16 | await csrf.validate(formData, headers)
17 | } catch (error) {
18 | if (error instanceof CSRFError) {
19 | throw new Response('Invalid CSRF token', { status: 403 })
20 | }
21 | throw error
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/utils/db.server.ts:
--------------------------------------------------------------------------------
1 | import { remember } from '@epic-web/remember'
2 | import { PrismaClient } from '@prisma/client'
3 | import chalk from 'chalk'
4 |
5 | export const prisma = remember('prisma', () => {
6 | // NOTE: if you change anything in this function you'll need to restart
7 | // the dev server to see your changes.
8 |
9 | // Feel free to change this log threshold to something that makes sense for you
10 | const logThreshold = 20
11 |
12 | const client = new PrismaClient({
13 | log: [
14 | { level: 'query', emit: 'event' },
15 | { level: 'error', emit: 'stdout' },
16 | { level: 'warn', emit: 'stdout' },
17 | ],
18 | })
19 | client.$on('query', async e => {
20 | if (e.duration < logThreshold) return
21 | const color =
22 | e.duration < logThreshold * 1.1
23 | ? 'green'
24 | : e.duration < logThreshold * 1.2
25 | ? 'blue'
26 | : e.duration < logThreshold * 1.3
27 | ? 'yellow'
28 | : e.duration < logThreshold * 1.4
29 | ? 'redBright'
30 | : 'red'
31 | const dur = chalk[color](`${e.duration}ms`)
32 | console.info(`prisma:query - ${dur} - ${e.query}`)
33 | })
34 | client.$connect()
35 | return client
36 | })
37 |
--------------------------------------------------------------------------------
/app/utils/email.server.ts:
--------------------------------------------------------------------------------
1 | import { renderAsync } from '@react-email/components'
2 | import { type ReactElement } from 'react'
3 | import { z } from 'zod'
4 |
5 | const resendErrorSchema = z.union([
6 | z.object({
7 | name: z.string(),
8 | message: z.string(),
9 | statusCode: z.number(),
10 | }),
11 | z.object({
12 | name: z.literal('UnknownError'),
13 | message: z.literal('Unknown Error'),
14 | statusCode: z.literal(500),
15 | cause: z.any(),
16 | }),
17 | ])
18 | type ResendError = z.infer
19 |
20 | const resendSuccessSchema = z.object({
21 | id: z.string(),
22 | })
23 |
24 | export async function sendEmail({
25 | react,
26 | ...options
27 | }: {
28 | to: string
29 | subject: string
30 | } & (
31 | | { html: string; text: string; react?: never }
32 | | { react: ReactElement; html?: never; text?: never }
33 | )) {
34 | const from = 'hello@epicstack.dev'
35 |
36 | const email = {
37 | from,
38 | ...options,
39 | ...(react ? await renderReactEmail(react) : null),
40 | }
41 |
42 | // feel free to remove this condition once you've set up resend
43 | if (!process.env.RESEND_API_KEY && !process.env.MOCKS) {
44 | console.error(`RESEND_API_KEY not set and we're not in mocks mode.`)
45 | console.error(
46 | `To send emails, set the RESEND_API_KEY environment variable.`,
47 | )
48 | console.error(`Would have sent the following email:`, JSON.stringify(email))
49 | return {
50 | status: 'success',
51 | data: { id: 'mocked' },
52 | } as const
53 | }
54 |
55 | const response = await fetch('https://api.resend.com/emails', {
56 | method: 'POST',
57 | body: JSON.stringify(email),
58 | headers: {
59 | Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
60 | 'Content-Type': 'application/json',
61 | },
62 | })
63 | const data = await response.json()
64 | const parsedData = resendSuccessSchema.safeParse(data)
65 |
66 | if (response.ok && parsedData.success) {
67 | return {
68 | status: 'success',
69 | data: parsedData,
70 | } as const
71 | } else {
72 | const parseResult = resendErrorSchema.safeParse(data)
73 | if (parseResult.success) {
74 | return {
75 | status: 'error',
76 | error: parseResult.data,
77 | } as const
78 | } else {
79 | return {
80 | status: 'error',
81 | error: {
82 | name: 'UnknownError',
83 | message: 'Unknown Error',
84 | statusCode: 500,
85 | cause: data,
86 | } satisfies ResendError,
87 | } as const
88 | }
89 | }
90 | }
91 |
92 | async function renderReactEmail(react: ReactElement) {
93 | const [html, text] = await Promise.all([
94 | renderAsync(react),
95 | renderAsync(react, { plainText: true }),
96 | ])
97 | return { html, text }
98 | }
99 |
--------------------------------------------------------------------------------
/app/utils/env.server.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | const schema = z.object({
4 | NODE_ENV: z.enum(['production', 'development', 'test'] as const),
5 | DATABASE_PATH: z.string(),
6 | DATABASE_URL: z.string(),
7 | SESSION_SECRET: z.string(),
8 | INTERNAL_COMMAND_TOKEN: z.string(),
9 | HONEYPOT_SECRET: z.string(),
10 | CACHE_DATABASE_PATH: z.string(),
11 | // If you plan on using Sentry, uncomment this line
12 | // SENTRY_DSN: z.string(),
13 | // If you plan to use Resend, uncomment this line
14 | // RESEND_API_KEY: z.string(),
15 | // If you plan to use GitHub auth, remove the default:
16 | GITHUB_CLIENT_ID: z.string().default('MOCK_GITHUB_CLIENT_ID'),
17 | GITHUB_CLIENT_SECRET: z.string().default('MOCK_GITHUB_CLIENT_SECRET'),
18 | GITHUB_TOKEN: z.string().default('MOCK_GITHUB_TOKEN'),
19 | })
20 |
21 | declare global {
22 | namespace NodeJS {
23 | interface ProcessEnv extends z.infer {}
24 | }
25 | }
26 |
27 | export function init() {
28 | const parsed = schema.safeParse(process.env)
29 |
30 | if (parsed.success === false) {
31 | console.error(
32 | '❌ Invalid environment variables:',
33 | parsed.error.flatten().fieldErrors,
34 | )
35 |
36 | throw new Error('Invalid environment variables')
37 | }
38 | }
39 |
40 | /**
41 | * This is used in both `entry.server.ts` and `root.tsx` to ensure that
42 | * the environment variables are set and globally available before the app is
43 | * started.
44 | *
45 | * NOTE: Do *not* add any environment variables in here that you do not wish to
46 | * be included in the client.
47 | * @returns all public ENV variables
48 | */
49 | export function getEnv() {
50 | return {
51 | MODE: process.env.NODE_ENV,
52 | SENTRY_DSN: process.env.SENTRY_DSN,
53 | }
54 | }
55 |
56 | type ENV = ReturnType
57 |
58 | declare global {
59 | var ENV: ENV
60 | interface Window {
61 | ENV: ENV
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/app/utils/extended-theme.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from 'tailwindcss'
2 |
3 | export const extendedTheme = {
4 | colors: {
5 | border: 'hsl(var(--border))',
6 | input: {
7 | DEFAULT: 'hsl(var(--input))',
8 | invalid: 'hsl(var(--input-invalid))',
9 | },
10 | ring: {
11 | DEFAULT: 'hsl(var(--ring))',
12 | invalid: 'hsl(var(--foreground-destructive))',
13 | },
14 | background: 'hsl(var(--background))',
15 | foreground: {
16 | DEFAULT: 'hsl(var(--foreground))',
17 | destructive: 'hsl(var(--foreground-destructive))',
18 | },
19 | primary: {
20 | DEFAULT: 'hsl(var(--primary))',
21 | foreground: 'hsl(var(--primary-foreground))',
22 | },
23 | secondary: {
24 | DEFAULT: 'hsl(var(--secondary))',
25 | foreground: 'hsl(var(--secondary-foreground))',
26 | },
27 | destructive: {
28 | DEFAULT: 'hsl(var(--destructive))',
29 | foreground: 'hsl(var(--destructive-foreground))',
30 | },
31 | muted: {
32 | DEFAULT: 'hsl(var(--muted))',
33 | foreground: 'hsl(var(--muted-foreground))',
34 | },
35 | accent: {
36 | DEFAULT: 'hsl(var(--accent))',
37 | foreground: 'hsl(var(--accent-foreground))',
38 | },
39 | popover: {
40 | DEFAULT: 'hsl(var(--popover))',
41 | foreground: 'hsl(var(--popover-foreground))',
42 | },
43 | card: {
44 | DEFAULT: 'hsl(var(--card))',
45 | foreground: 'hsl(var(--card-foreground))',
46 | },
47 | },
48 | borderColor: {
49 | DEFAULT: 'hsl(var(--border))',
50 | },
51 | borderRadius: {
52 | lg: 'var(--radius)',
53 | md: 'calc(var(--radius) - 2px)',
54 | sm: 'calc(var(--radius) - 4px)',
55 | },
56 | fontFamily: {
57 | poppins: ['Poppins', 'sans-serif'],
58 | 'bowlby-one': ['Bowlby One', 'sans-serif'],
59 | },
60 | fontSize: {
61 | // 1rem = 16px
62 | /** 80px size / 84px high / bold */
63 | mega: ['5rem', { lineHeight: '5.25rem', fontWeight: '700' }],
64 | /** 56px size / 62px high / bold */
65 | h1: ['3.5rem', { lineHeight: '3.875rem', fontWeight: '700' }],
66 | /** 40px size / 48px high / bold */
67 | h2: ['2.5rem', { lineHeight: '3rem', fontWeight: '700' }],
68 | /** 32px size / 36px high / bold */
69 | h3: ['2rem', { lineHeight: '2.25rem', fontWeight: '700' }],
70 | /** 28px size / 36px high / bold */
71 | h4: ['1.75rem', { lineHeight: '2.25rem', fontWeight: '700' }],
72 | /** 24px size / 32px high / bold */
73 | h5: ['1.5rem', { lineHeight: '2rem', fontWeight: '700' }],
74 | /** 16px size / 20px high / bold */
75 | h6: ['1rem', { lineHeight: '1.25rem', fontWeight: '700' }],
76 |
77 | /** 32px size / 36px high / normal */
78 | 'body-2xl': ['2rem', { lineHeight: '2.25rem' }],
79 | /** 28px size / 36px high / normal */
80 | 'body-xl': ['1.75rem', { lineHeight: '2.25rem' }],
81 | /** 24px size / 32px high / normal */
82 | 'body-lg': ['1.5rem', { lineHeight: '2rem' }],
83 | /** 20px size / 28px high / normal */
84 | 'body-md': ['1.25rem', { lineHeight: '1.75rem' }],
85 | /** 16px size / 20px high / normal */
86 | 'body-sm': ['1rem', { lineHeight: '1.25rem' }],
87 | /** 14px size / 18px high / normal */
88 | 'body-xs': ['0.875rem', { lineHeight: '1.125rem' }],
89 | /** 12px size / 16px high / normal */
90 | 'body-2xs': ['0.75rem', { lineHeight: '1rem' }],
91 |
92 | /** 18px size / 24px high / semibold */
93 | caption: ['1.125rem', { lineHeight: '1.5rem', fontWeight: '600' }],
94 | /** 12px size / 16px high / bold */
95 | button: ['0.75rem', { lineHeight: '1rem', fontWeight: '700' }],
96 | },
97 | keyframes: {
98 | 'accordion-down': {
99 | from: { height: '0' },
100 | to: { height: 'var(--radix-accordion-content-height)' },
101 | },
102 | 'accordion-up': {
103 | from: { height: 'var(--radix-accordion-content-height)' },
104 | to: { height: '0' },
105 | },
106 | space: {
107 | '0%, 100%': { transform: 'translateX(0) rotate(0)' },
108 | '25%': { transform: 'translateX(2%) rotate(-1deg) scale(1.1)' },
109 | '50%': { transform: 'translateX(5%) rotate(1deg) scale(1.15)' },
110 | '75%': { transform: 'translateX(2%) rotate(-1deg) scale(1.1)' },
111 | },
112 | },
113 | animation: {
114 | 'accordion-down': 'accordion-down 0.2s ease-out',
115 | 'accordion-up': 'accordion-up 0.2s ease-out',
116 | space: 'space 10s infinite ease-in-out',
117 | },
118 | } satisfies Config['theme']
119 |
--------------------------------------------------------------------------------
/app/utils/format-step.ts:
--------------------------------------------------------------------------------
1 | export const formatStep: (index: number) => string = (index: number) =>
2 | Number(index).toString().padStart(2, '0')
3 |
--------------------------------------------------------------------------------
/app/utils/get-routes.server.ts:
--------------------------------------------------------------------------------
1 | import { glob } from 'glob'
2 | export const getRoutes = async (pattern: string) => await glob(pattern)
3 |
--------------------------------------------------------------------------------
/app/utils/honeypot.server.ts:
--------------------------------------------------------------------------------
1 | import { Honeypot, SpamError } from 'remix-utils/honeypot/server'
2 |
3 | export const honeypot = new Honeypot({
4 | validFromFieldName: process.env.TESTING ? null : undefined,
5 | encryptionSeed: process.env.HONEYPOT_SECRET,
6 | })
7 |
8 | export function checkHoneypot(formData: FormData) {
9 | try {
10 | honeypot.check(formData)
11 | } catch (error) {
12 | if (error instanceof SpamError) {
13 | throw new Response('Form not submitted properly', { status: 400 })
14 | }
15 | throw error
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/utils/litefs.server.ts:
--------------------------------------------------------------------------------
1 | // litefs-js should be used server-side only. It imports `fs` which results in Remix
2 | // including a big polyfill. So we put the import in a `.server.ts` file to avoid that
3 | // polyfill from being included. https://github.com/epicweb-dev/epic-stack/pull/331
4 | export * from 'litefs-js'
5 | export * from 'litefs-js/remix.js'
6 |
--------------------------------------------------------------------------------
/app/utils/misc.error-message.test.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker'
2 | import { expect, test } from 'vitest'
3 | import { consoleError } from '#tests/setup/setup-test-env.ts'
4 | import { getErrorMessage } from './misc.tsx'
5 |
6 | test('Error object returns message', () => {
7 | const message = faker.lorem.words(2)
8 | expect(getErrorMessage(new Error(message))).toBe(message)
9 | })
10 |
11 | test('String returns itself', () => {
12 | const message = faker.lorem.words(2)
13 | expect(getErrorMessage(message)).toBe(message)
14 | })
15 |
16 | test('undefined falls back to Unknown', () => {
17 | consoleError.mockImplementation(() => {})
18 | expect(getErrorMessage(undefined)).toBe('Unknown Error')
19 | expect(consoleError).toHaveBeenCalledWith(
20 | 'Unable to get error message for error',
21 | undefined,
22 | )
23 | expect(consoleError).toHaveBeenCalledTimes(1)
24 | })
25 |
--------------------------------------------------------------------------------
/app/utils/misc.use-double-check.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @vitest-environment jsdom
3 | */
4 | import { render, screen } from '@testing-library/react'
5 | import { userEvent } from '@testing-library/user-event'
6 | import { useState } from 'react'
7 | import { expect, test } from 'vitest'
8 | import { useDoubleCheck } from './misc.tsx'
9 |
10 | function TestComponent() {
11 | const [defaultPrevented, setDefaultPrevented] = useState<
12 | 'idle' | 'no' | 'yes'
13 | >('idle')
14 | const dc = useDoubleCheck()
15 | return (
16 |
17 | Default Prevented: {defaultPrevented}
18 | setDefaultPrevented(e.defaultPrevented ? 'yes' : 'no'),
21 | })}
22 | >
23 | {dc.doubleCheck ? 'You sure?' : 'Click me'}
24 |
25 |
26 | )
27 | }
28 |
29 | test('prevents default on the first click, and does not on the second', async () => {
30 | const user = userEvent.setup()
31 | render( )
32 |
33 | const status = screen.getByRole('status')
34 | const button = screen.getByRole('button')
35 |
36 | expect(status).toHaveTextContent('Default Prevented: idle')
37 | expect(button).toHaveTextContent('Click me')
38 |
39 | await user.click(button)
40 | expect(button).toHaveTextContent('You sure?')
41 | expect(status).toHaveTextContent('Default Prevented: yes')
42 |
43 | await user.click(button)
44 | expect(button).toHaveTextContent('You sure?')
45 | expect(status).toHaveTextContent('Default Prevented: no')
46 | })
47 |
48 | test('blurring the button starts things over', async () => {
49 | const user = userEvent.setup()
50 | render( )
51 |
52 | const status = screen.getByRole('status')
53 | const button = screen.getByRole('button')
54 |
55 | await user.click(button)
56 | expect(button).toHaveTextContent('You sure?')
57 | expect(status).toHaveTextContent('Default Prevented: yes')
58 |
59 | await user.click(document.body)
60 | // button goes back to click me
61 | expect(button).toHaveTextContent('Click me')
62 | // our callback wasn't called, so the status doesn't change
63 | expect(status).toHaveTextContent('Default Prevented: yes')
64 | })
65 |
66 | test('hitting "escape" on the input starts things over', async () => {
67 | const user = userEvent.setup()
68 | render( )
69 |
70 | const status = screen.getByRole('status')
71 | const button = screen.getByRole('button')
72 |
73 | await user.click(button)
74 | expect(button).toHaveTextContent('You sure?')
75 | expect(status).toHaveTextContent('Default Prevented: yes')
76 |
77 | await user.keyboard('{Escape}')
78 | // button goes back to click me
79 | expect(button).toHaveTextContent('Click me')
80 | // our callback wasn't called, so the status doesn't change
81 | expect(status).toHaveTextContent('Default Prevented: yes')
82 | })
83 |
--------------------------------------------------------------------------------
/app/utils/monitoring.client.tsx:
--------------------------------------------------------------------------------
1 | import { useLocation, useMatches } from '@remix-run/react'
2 | import * as Sentry from '@sentry/remix'
3 | import { useEffect } from 'react'
4 |
5 | export function init() {
6 | Sentry.init({
7 | dsn: ENV.SENTRY_DSN,
8 | environment: ENV.MODE,
9 | integrations: [
10 | new Sentry.BrowserTracing({
11 | routingInstrumentation: Sentry.remixRouterInstrumentation(
12 | useEffect,
13 | useLocation,
14 | useMatches,
15 | ),
16 | }),
17 | // Replay is only available in the client
18 | new Sentry.Replay(),
19 | new Sentry.BrowserProfilingIntegration(),
20 | ],
21 |
22 | // Set tracesSampleRate to 1.0 to capture 100%
23 | // of transactions for performance monitoring.
24 | // We recommend adjusting this value in production
25 | tracesSampleRate: 1.0,
26 |
27 | // Capture Replay for 10% of all sessions,
28 | // plus for 100% of sessions with an error
29 | replaysSessionSampleRate: 0.1,
30 | replaysOnErrorSampleRate: 1.0,
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/app/utils/monitoring.server.ts:
--------------------------------------------------------------------------------
1 | import { ProfilingIntegration } from '@sentry/profiling-node'
2 | import * as Sentry from '@sentry/remix'
3 | import { prisma } from './db.server.ts'
4 |
5 | export function init() {
6 | Sentry.init({
7 | dsn: ENV.SENTRY_DSN,
8 | environment: ENV.MODE,
9 | tracesSampleRate: ENV.MODE === 'production' ? 1 : 0,
10 | denyUrls: [
11 | /\/resources\/healthcheck/,
12 | // TODO: be smarter about the public assets...
13 | /\/build\//,
14 | /\/favicons\//,
15 | /\/img\//,
16 | /\/fonts\//,
17 | /\/favicon.ico/,
18 | /\/site\.webmanifest/,
19 | ],
20 | integrations: [
21 | new Sentry.Integrations.Http({ tracing: true }),
22 | new Sentry.Integrations.Prisma({ client: prisma }),
23 | new ProfilingIntegration(),
24 | ],
25 | tracesSampler(samplingContext) {
26 | // ignore healthcheck transactions by other services (consul, etc.)
27 | if (samplingContext.request?.url?.includes('/resources/healthcheck')) {
28 | return 0
29 | }
30 | return 1
31 | },
32 | beforeSendTransaction(event) {
33 | // ignore all healthcheck related transactions
34 | // note that name of header here is case-sensitive
35 | if (event.request?.headers?.['x-healthcheck'] === 'true') {
36 | return null
37 | }
38 |
39 | return event
40 | },
41 | })
42 | }
43 |
--------------------------------------------------------------------------------
/app/utils/nonce-provider.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export const NonceContext = React.createContext('')
4 | export const NonceProvider = NonceContext.Provider
5 | export const useNonce = () => React.useContext(NonceContext)
6 |
--------------------------------------------------------------------------------
/app/utils/permissions.ts:
--------------------------------------------------------------------------------
1 | import { json } from '@remix-run/node'
2 | import { requireUserId } from './auth.server.ts'
3 | import { prisma } from './db.server.ts'
4 | import { type useUser } from './user.ts'
5 |
6 | export async function requireUserWithPermission(
7 | request: Request,
8 | permission: PermissionString,
9 | ) {
10 | const userId = await requireUserId(request)
11 | const permissionData = parsePermissionString(permission)
12 | const user = await prisma.user.findFirst({
13 | select: { id: true },
14 | where: {
15 | id: userId,
16 | roles: {
17 | some: {
18 | permissions: {
19 | some: {
20 | ...permissionData,
21 | access: permissionData.access
22 | ? { in: permissionData.access }
23 | : undefined,
24 | },
25 | },
26 | },
27 | },
28 | },
29 | })
30 | if (!user) {
31 | throw json(
32 | {
33 | error: 'Unauthorized',
34 | requiredPermission: permissionData,
35 | message: `Unauthorized: required permissions: ${permission}`,
36 | },
37 | { status: 403 },
38 | )
39 | }
40 | return user.id
41 | }
42 |
43 | export async function requireUserWithRole(request: Request, name: string) {
44 | const userId = await requireUserId(request)
45 | const user = await prisma.user.findFirst({
46 | select: { id: true },
47 | where: { id: userId, roles: { some: { name } } },
48 | })
49 | if (!user) {
50 | throw json(
51 | {
52 | error: 'Unauthorized',
53 | requiredRole: name,
54 | message: `Unauthorized: required role: ${name}`,
55 | },
56 | { status: 403 },
57 | )
58 | }
59 | return user.id
60 | }
61 |
62 | type Action = 'create' | 'read' | 'update' | 'delete'
63 | type Entity = 'user' | 'note'
64 | type Access = 'own' | 'any' | 'own,any' | 'any,own'
65 | type PermissionString = `${Action}:${Entity}` | `${Action}:${Entity}:${Access}`
66 | function parsePermissionString(permissionString: PermissionString) {
67 | const [action, entity, access] = permissionString.split(':') as [
68 | Action,
69 | Entity,
70 | Access | undefined,
71 | ]
72 | return {
73 | action,
74 | entity,
75 | access: access ? (access.split(',') as Array) : undefined,
76 | }
77 | }
78 |
79 | export function userHasPermission(
80 | user: Pick, 'roles'> | null | undefined,
81 | permission: PermissionString,
82 | ) {
83 | if (!user) return false
84 | const { action, entity, access } = parsePermissionString(permission)
85 | return user.roles.some(role =>
86 | role.permissions.some(
87 | permission =>
88 | permission.entity === entity &&
89 | permission.action === action &&
90 | (!access || access.includes(permission.access)),
91 | ),
92 | )
93 | }
94 |
95 | export function userHasRole(
96 | user: Pick, 'roles'> | null,
97 | role: string,
98 | ) {
99 | if (!user) return false
100 | return user.roles.some(r => r.name === role)
101 | }
102 |
--------------------------------------------------------------------------------
/app/utils/providers/github.server.ts:
--------------------------------------------------------------------------------
1 | import { createId as cuid } from '@paralleldrive/cuid2'
2 | import { redirect } from '@remix-run/node'
3 | import { GitHubStrategy } from 'remix-auth-github'
4 | import { z } from 'zod'
5 | import { cache, cachified } from '../cache.server.ts'
6 | import { connectionSessionStorage } from '../connections.server.ts'
7 | import { type Timings } from '../timing.server.ts'
8 | import { type AuthProvider } from './provider.ts'
9 |
10 | const GitHubUserSchema = z.object({ login: z.string() })
11 | const GitHubUserParseResult = z
12 | .object({
13 | success: z.literal(true),
14 | data: GitHubUserSchema,
15 | })
16 | .or(
17 | z.object({
18 | success: z.literal(false),
19 | }),
20 | )
21 |
22 | const shouldMock = process.env.GITHUB_CLIENT_ID?.startsWith('MOCK_')
23 |
24 | export class GitHubProvider implements AuthProvider {
25 | getAuthStrategy() {
26 | return new GitHubStrategy(
27 | {
28 | clientID: process.env.GITHUB_CLIENT_ID,
29 | clientSecret: process.env.GITHUB_CLIENT_SECRET,
30 | callbackURL: '/auth/github/callback',
31 | },
32 | async ({ profile }) => {
33 | const email = profile.emails[0].value.trim().toLowerCase()
34 | const username = profile.displayName
35 | const imageUrl = profile.photos[0].value
36 | return {
37 | email,
38 | id: profile.id,
39 | username,
40 | name: profile.name.givenName,
41 | imageUrl,
42 | }
43 | },
44 | )
45 | }
46 |
47 | async resolveConnectionData(
48 | providerId: string,
49 | { timings }: { timings?: Timings } = {},
50 | ) {
51 | const result = await cachified({
52 | key: `connection-data:github:${providerId}`,
53 | cache,
54 | timings,
55 | ttl: 1000 * 60,
56 | swr: 1000 * 60 * 60 * 24 * 7,
57 | async getFreshValue(context) {
58 | await new Promise(r => setTimeout(r, 3000))
59 | const response = await fetch(
60 | `https://api.github.com/user/${providerId}`,
61 | { headers: { Authorization: `token ${process.env.GITHUB_TOKEN}` } },
62 | )
63 | const rawJson = await response.json()
64 | const result = GitHubUserSchema.safeParse(rawJson)
65 | if (!result.success) {
66 | // if it was unsuccessful, then we should kick it out of the cache
67 | // asap and try again.
68 | context.metadata.ttl = 0
69 | }
70 | return result
71 | },
72 | checkValue: GitHubUserParseResult,
73 | })
74 | return {
75 | displayName: result.success ? result.data.login : 'Unknown',
76 | link: result.success ? `https://github.com/${result.data.login}` : null,
77 | } as const
78 | }
79 |
80 | async handleMockAction(request: Request) {
81 | if (!shouldMock) return
82 |
83 | const connectionSession = await connectionSessionStorage.getSession(
84 | request.headers.get('cookie'),
85 | )
86 | const state = cuid()
87 | connectionSession.set('oauth2:state', state)
88 | const code = 'MOCK_CODE_GITHUB_KODY'
89 | const searchParams = new URLSearchParams({ code, state })
90 | throw redirect(`/auth/github/callback?${searchParams}`, {
91 | headers: {
92 | 'set-cookie':
93 | await connectionSessionStorage.commitSession(connectionSession),
94 | },
95 | })
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/app/utils/providers/provider.ts:
--------------------------------------------------------------------------------
1 | import { type Strategy } from 'remix-auth'
2 | import { type Timings } from '../timing.server.ts'
3 |
4 | // Define a user type for cleaner typing
5 | export type ProviderUser = {
6 | id: string
7 | email: string
8 | username?: string
9 | name?: string
10 | imageUrl?: string
11 | }
12 |
13 | export interface AuthProvider {
14 | getAuthStrategy(): Strategy
15 | handleMockAction(request: Request): Promise
16 | resolveConnectionData(
17 | providerId: string,
18 | options?: { timings?: Timings },
19 | ): Promise<{
20 | displayName: string
21 | link?: string | null
22 | }>
23 | }
24 |
--------------------------------------------------------------------------------
/app/utils/redirect-cookie.server.ts:
--------------------------------------------------------------------------------
1 | import * as cookie from 'cookie'
2 |
3 | const key = 'redirectTo'
4 | export const destroyRedirectToHeader = cookie.serialize(key, '', { maxAge: -1 })
5 |
6 | export function getRedirectCookieHeader(redirectTo?: string) {
7 | return redirectTo && redirectTo !== '/'
8 | ? cookie.serialize(key, redirectTo, { maxAge: 60 * 10 })
9 | : null
10 | }
11 |
12 | export function getRedirectCookieValue(request: Request) {
13 | const rawCookie = request.headers.get('cookie')
14 | const parsedCookies = rawCookie ? cookie.parse(rawCookie) : {}
15 | const redirectTo = parsedCookies[key]
16 | return redirectTo || null
17 | }
18 |
--------------------------------------------------------------------------------
/app/utils/request-info.ts:
--------------------------------------------------------------------------------
1 | import { invariant } from '@epic-web/invariant'
2 | import { useRouteLoaderData } from '@remix-run/react'
3 | import { type loader as rootLoader } from '#app/root.tsx'
4 |
5 | /**
6 | * @returns the request info from the root loader
7 | */
8 | export function useRequestInfo() {
9 | const data = useRouteLoaderData('root')
10 | invariant(data?.requestInfo, 'No requestInfo found in root loader')
11 |
12 | return data.requestInfo
13 | }
14 |
--------------------------------------------------------------------------------
/app/utils/session.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from '@remix-run/node'
2 |
3 | export const authSessionStorage = createCookieSessionStorage({
4 | cookie: {
5 | name: 'en_session',
6 | sameSite: 'lax',
7 | path: '/',
8 | httpOnly: true,
9 | secrets: process.env.SESSION_SECRET.split(','),
10 | secure: process.env.NODE_ENV === 'production',
11 | },
12 | })
13 |
14 | // we have to do this because every time you commit the session you overwrite it
15 | // so we store the expiration time in the cookie and reset it every time we commit
16 | const originalCommitSession = authSessionStorage.commitSession
17 |
18 | Object.defineProperty(authSessionStorage, 'commitSession', {
19 | value: async function commitSession(
20 | ...args: Parameters
21 | ) {
22 | const [session, options] = args
23 | if (options?.expires) {
24 | session.set('expires', options.expires)
25 | }
26 | if (options?.maxAge) {
27 | session.set('expires', new Date(Date.now() + options.maxAge * 1000))
28 | }
29 | const expires = session.has('expires')
30 | ? new Date(session.get('expires'))
31 | : undefined
32 | const setCookieHeader = await originalCommitSession(session, {
33 | ...options,
34 | expires,
35 | })
36 | return setCookieHeader
37 | },
38 | })
39 |
--------------------------------------------------------------------------------
/app/utils/theme.server.ts:
--------------------------------------------------------------------------------
1 | import * as cookie from 'cookie'
2 |
3 | const cookieName = 'en_theme'
4 | export type Theme = 'light' | 'dark'
5 |
6 | export function getTheme(request: Request): Theme | null {
7 | const cookieHeader = request.headers.get('cookie')
8 | const parsed = cookieHeader ? cookie.parse(cookieHeader)[cookieName] : 'light'
9 | if (parsed === 'light' || parsed === 'dark') return parsed
10 | return null
11 | }
12 |
13 | export function setTheme(theme: Theme | 'system') {
14 | if (theme === 'system') {
15 | return cookie.serialize(cookieName, '', { path: '/', maxAge: -1 })
16 | } else {
17 | return cookie.serialize(cookieName, theme, { path: '/', maxAge: 31536000 })
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/utils/timing.server.ts:
--------------------------------------------------------------------------------
1 | import { type CreateReporter } from '@epic-web/cachified'
2 |
3 | export type Timings = Record<
4 | string,
5 | Array<
6 | { desc?: string } & (
7 | | { time: number; start?: never }
8 | | { time?: never; start: number }
9 | )
10 | >
11 | >
12 |
13 | export function makeTimings(type: string, desc?: string) {
14 | const timings: Timings = {
15 | [type]: [{ desc, start: performance.now() }],
16 | }
17 | Object.defineProperty(timings, 'toString', {
18 | value: function () {
19 | return getServerTimeHeader(timings)
20 | },
21 | enumerable: false,
22 | })
23 | return timings
24 | }
25 |
26 | function createTimer(type: string, desc?: string) {
27 | const start = performance.now()
28 | return {
29 | end(timings: Timings) {
30 | let timingType = timings[type]
31 |
32 | if (!timingType) {
33 | // eslint-disable-next-line no-multi-assign
34 | timingType = timings[type] = []
35 | }
36 | timingType.push({ desc, time: performance.now() - start })
37 | },
38 | }
39 | }
40 |
41 | export async function time(
42 | fn: Promise | (() => ReturnType | Promise),
43 | {
44 | type,
45 | desc,
46 | timings,
47 | }: {
48 | type: string
49 | desc?: string
50 | timings?: Timings
51 | },
52 | ): Promise {
53 | const timer = createTimer(type, desc)
54 | const promise = typeof fn === 'function' ? fn() : fn
55 | if (!timings) return promise
56 |
57 | const result = await promise
58 |
59 | timer.end(timings)
60 | return result
61 | }
62 |
63 | export function getServerTimeHeader(timings?: Timings) {
64 | if (!timings) return ''
65 | return Object.entries(timings)
66 | .map(([key, timingInfos]) => {
67 | const dur = timingInfos
68 | .reduce((acc, timingInfo) => {
69 | const time = timingInfo.time ?? performance.now() - timingInfo.start
70 | return acc + time
71 | }, 0)
72 | .toFixed(1)
73 | const desc = timingInfos
74 | .map(t => t.desc)
75 | .filter(Boolean)
76 | .join(' & ')
77 | return [
78 | key.replaceAll(/(:| |@|=|;|,|\/|\\)/g, '_'),
79 | desc ? `desc=${JSON.stringify(desc)}` : null,
80 | `dur=${dur}`,
81 | ]
82 | .filter(Boolean)
83 | .join(';')
84 | })
85 | .join(',')
86 | }
87 |
88 | export function combineServerTimings(headers1: Headers, headers2: Headers) {
89 | const newHeaders = new Headers(headers1)
90 | newHeaders.append('Server-Timing', headers2.get('Server-Timing') ?? '')
91 | return newHeaders.get('Server-Timing') ?? ''
92 | }
93 |
94 | export function cachifiedTimingReporter(
95 | timings?: Timings,
96 | ): undefined | CreateReporter {
97 | if (!timings) return
98 |
99 | return ({ key }) => {
100 | const cacheRetrievalTimer = createTimer(
101 | `cache:${key}`,
102 | `${key} cache retrieval`,
103 | )
104 | let getFreshValueTimer: ReturnType | undefined
105 | return event => {
106 | switch (event.name) {
107 | case 'getFreshValueStart':
108 | getFreshValueTimer = createTimer(
109 | `getFreshValue:${key}`,
110 | `request forced to wait for a fresh ${key} value`,
111 | )
112 | break
113 | case 'getFreshValueSuccess':
114 | getFreshValueTimer?.end(timings)
115 | break
116 | case 'done':
117 | cacheRetrievalTimer.end(timings)
118 | break
119 | }
120 | }
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/app/utils/toast.server.ts:
--------------------------------------------------------------------------------
1 | import { createId as cuid } from '@paralleldrive/cuid2'
2 | import { createCookieSessionStorage, redirect } from '@remix-run/node'
3 | import { z } from 'zod'
4 | import { combineHeaders } from './misc.tsx'
5 |
6 | export const toastKey = 'toast'
7 |
8 | const TypeSchema = z.enum(['message', 'success', 'error'])
9 | const ToastSchema = z.object({
10 | description: z.string(),
11 | id: z.string().default(() => cuid()),
12 | title: z.string().optional(),
13 | type: TypeSchema.default('message'),
14 | })
15 |
16 | export type Toast = z.infer
17 | export type OptionalToast = Omit & {
18 | id?: string
19 | type?: z.infer
20 | }
21 |
22 | export const toastSessionStorage = createCookieSessionStorage({
23 | cookie: {
24 | name: 'en_toast',
25 | sameSite: 'lax',
26 | path: '/',
27 | httpOnly: true,
28 | secrets: process.env.SESSION_SECRET.split(','),
29 | secure: process.env.NODE_ENV === 'production',
30 | },
31 | })
32 |
33 | export async function redirectWithToast(
34 | url: string,
35 | toast: OptionalToast,
36 | init?: ResponseInit,
37 | ) {
38 | return redirect(url, {
39 | ...init,
40 | headers: combineHeaders(init?.headers, await createToastHeaders(toast)),
41 | })
42 | }
43 |
44 | export async function createToastHeaders(optionalToast: OptionalToast) {
45 | const session = await toastSessionStorage.getSession()
46 | const toast = ToastSchema.parse(optionalToast)
47 | session.flash(toastKey, toast)
48 | const cookie = await toastSessionStorage.commitSession(session)
49 | return new Headers({ 'set-cookie': cookie })
50 | }
51 |
52 | export async function getToast(request: Request) {
53 | const session = await toastSessionStorage.getSession(
54 | request.headers.get('cookie'),
55 | )
56 | const result = ToastSchema.safeParse(session.get(toastKey))
57 | const toast = result.success ? result.data : null
58 | return {
59 | toast,
60 | headers: toast
61 | ? new Headers({
62 | 'set-cookie': await toastSessionStorage.destroySession(session),
63 | })
64 | : null,
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/app/utils/totp.server.ts:
--------------------------------------------------------------------------------
1 | // @epic-web/totp should be used server-side only. It imports `Crypto` which results in Remix
2 | // including a big polyfill. So we put the import in a `.server.ts` file to avoid that
3 | export * from '@epic-web/totp'
4 |
--------------------------------------------------------------------------------
/app/utils/user-validation.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const UsernameSchema = z
4 | .string({ required_error: 'Username is required' })
5 | .min(3, { message: 'Username is too short' })
6 | .max(20, { message: 'Username is too long' })
7 | .regex(/^[a-zA-Z0-9_]+$/, {
8 | message: 'Username can only include letters, numbers, and underscores',
9 | })
10 | // users can type the username in any case, but we store it in lowercase
11 | .transform(value => value.toLowerCase())
12 |
13 | export const PasswordSchema = z
14 | .string({ required_error: 'Password is required' })
15 | .min(6, { message: 'Password is too short' })
16 | .max(100, { message: 'Password is too long' })
17 | export const NameSchema = z
18 | .string({ required_error: 'Name is required' })
19 | .min(3, { message: 'Name is too short' })
20 | .max(40, { message: 'Name is too long' })
21 | export const EmailSchema = z
22 | .string({ required_error: 'Email is required' })
23 | .email({ message: 'Email is invalid' })
24 | .min(3, { message: 'Email is too short' })
25 | .max(100, { message: 'Email is too long' })
26 | // users can type the email in any case, but we store it in lowercase
27 | .transform(value => value.toLowerCase())
28 |
29 | export const PasswordAndConfirmPasswordSchema = z
30 | .object({ password: PasswordSchema, confirmPassword: PasswordSchema })
31 | .superRefine(({ confirmPassword, password }, ctx) => {
32 | if (confirmPassword !== password) {
33 | ctx.addIssue({
34 | path: ['confirmPassword'],
35 | code: 'custom',
36 | message: 'The passwords must match',
37 | })
38 | }
39 | })
40 |
--------------------------------------------------------------------------------
/app/utils/user.ts:
--------------------------------------------------------------------------------
1 | import { type SerializeFrom } from '@remix-run/node'
2 | import { useRouteLoaderData } from '@remix-run/react'
3 | import { type loader as rootLoader } from '#app/root.tsx'
4 |
5 | function isUser(user: any): user is SerializeFrom['user'] {
6 | return user && typeof user === 'object' && typeof user.id === 'string'
7 | }
8 |
9 | export function useOptionalUser() {
10 | const data = useRouteLoaderData('root')
11 | if (!data || !isUser(data.user)) {
12 | return undefined
13 | }
14 | return data.user
15 | }
16 |
17 | export function useUser() {
18 | const maybeUser = useOptionalUser()
19 | if (!maybeUser) {
20 | throw new Error(
21 | 'No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead.',
22 | )
23 | }
24 | return maybeUser
25 | }
26 |
--------------------------------------------------------------------------------
/app/utils/verification.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from '@remix-run/node'
2 |
3 | export const verifySessionStorage = createCookieSessionStorage({
4 | cookie: {
5 | name: 'en_verification',
6 | sameSite: 'lax',
7 | path: '/',
8 | httpOnly: true,
9 | maxAge: 60 * 10, // 10 minutes
10 | secrets: process.env.SESSION_SECRET.split(','),
11 | secure: process.env.NODE_ENV === 'production',
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tailwind": {
6 | "config": "tailwind.config.ts",
7 | "css": "app/styles/tailwind.css",
8 | "baseColor": "slate",
9 | "cssVariables": true
10 | },
11 | "aliases": {
12 | "components": "#app/components",
13 | "utils": "#app/utils/misc.tsx"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | app = "simons-epic-tips-da59"
2 | primary_region = "sjc"
3 | kill_signal = "SIGINT"
4 | kill_timeout = 5
5 | processes = [ ]
6 |
7 | [experimental]
8 | allowed_public_ports = [ ]
9 | auto_rollback = true
10 |
11 | [mounts]
12 | source = "data"
13 | destination = "/data"
14 |
15 | [deploy]
16 | release_command = "node ./other/sentry-create-release"
17 |
18 | [[services]]
19 | internal_port = 8080
20 | processes = [ "app" ]
21 | protocol = "tcp"
22 | script_checks = [ ]
23 |
24 | [services.concurrency]
25 | hard_limit = 100
26 | soft_limit = 80
27 | type = "requests"
28 |
29 | [[services.ports]]
30 | handlers = [ "http" ]
31 | port = 80
32 | force_https = true
33 |
34 | [[services.ports]]
35 | handlers = [ "tls", "http" ]
36 | port = 443
37 |
38 | [[services.tcp_checks]]
39 | grace_period = "1s"
40 | interval = "15s"
41 | restart_limit = 0
42 | timeout = "2s"
43 |
44 | [[services.http_checks]]
45 | interval = "10s"
46 | grace_period = "5s"
47 | method = "get"
48 | path = "/resources/healthcheck"
49 | protocol = "http"
50 | timeout = "2s"
51 | tls_skip_verify = false
52 | headers = { }
53 |
54 | [[services.http_checks]]
55 | grace_period = "10s"
56 | interval = "30s"
57 | method = "GET"
58 | timeout = "5s"
59 | path = "/litefs/health"
60 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import * as fs from 'fs'
3 | import { installGlobals } from '@remix-run/node'
4 | import chalk from 'chalk'
5 | import closeWithGrace from 'close-with-grace'
6 | import sourceMapSupport from 'source-map-support'
7 |
8 | sourceMapSupport.install({
9 | retrieveSourceMap: function (source) {
10 | // get source file without the `file://` prefix or `?t=...` suffix
11 | const match = source.match(/^file:\/\/(.*)\?t=[.\d]+$/)
12 | if (match) {
13 | return {
14 | url: source,
15 | map: fs.readFileSync(`${match[1]}.map`, 'utf8'),
16 | }
17 | }
18 | return null
19 | },
20 | })
21 |
22 | installGlobals()
23 |
24 | closeWithGrace(async ({ err }) => {
25 | if (err) {
26 | console.error(chalk.red(err))
27 | console.error(chalk.red(err.stack))
28 | process.exit(1)
29 | }
30 | })
31 |
32 | if (process.env.MOCKS === 'true') {
33 | await import('./tests/mocks/index.ts')
34 | }
35 |
36 | if (process.env.NODE_ENV === 'production') {
37 | await import('./server-build/index.js')
38 | } else {
39 | await import('./server/index.ts')
40 | }
41 |
--------------------------------------------------------------------------------
/other/.dockerignore:
--------------------------------------------------------------------------------
1 | # This file is moved to the root directory before building the image
2 |
3 | /node_modules
4 | *.log
5 | .DS_Store
6 | .env
7 | /.cache
8 | /public/build
9 | /build
10 |
--------------------------------------------------------------------------------
/other/Dockerfile:
--------------------------------------------------------------------------------
1 | # This file is moved to the root directory before building the image
2 |
3 | # base node image
4 | FROM node:20-bookworm-slim as base
5 |
6 | # set for base and all layer that inherit from it
7 | ENV NODE_ENV production
8 |
9 | # Install openssl for Prisma
10 | RUN apt-get update && apt-get install -y fuse3 openssl sqlite3 ca-certificates
11 |
12 | # Install all node_modules, including dev dependencies
13 | FROM base as deps
14 |
15 | WORKDIR /myapp
16 |
17 | ADD package.json package-lock.json .npmrc ./
18 | RUN npm install --include=dev
19 |
20 | # Setup production node_modules
21 | FROM base as production-deps
22 |
23 | WORKDIR /myapp
24 |
25 | COPY --from=deps /myapp/node_modules /myapp/node_modules
26 | ADD package.json package-lock.json .npmrc ./
27 | RUN npm prune --omit=dev
28 |
29 | # Build the app
30 | FROM base as build
31 |
32 | WORKDIR /myapp
33 |
34 | COPY --from=deps /myapp/node_modules /myapp/node_modules
35 |
36 | ADD prisma .
37 | RUN npx prisma generate
38 |
39 | ADD . .
40 | RUN npm run build
41 |
42 | # Finally, build the production image with minimal footprint
43 | FROM base
44 |
45 | ENV FLY="true"
46 | ENV LITEFS_DIR="/litefs/data"
47 | ENV DATABASE_FILENAME="sqlite.db"
48 | ENV DATABASE_PATH="$LITEFS_DIR/$DATABASE_FILENAME"
49 | ENV DATABASE_URL="file:$DATABASE_PATH"
50 | ENV CACHE_DATABASE_FILENAME="cache.db"
51 | ENV CACHE_DATABASE_PATH="/$LITEFS_DIR/$CACHE_DATABASE_FILENAME"
52 | ENV INTERNAL_PORT="8080"
53 | ENV PORT="8081"
54 | ENV NODE_ENV="production"
55 |
56 | # add shortcut for connecting to database CLI
57 | RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
58 |
59 | WORKDIR /myapp
60 |
61 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules
62 | COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma
63 |
64 | COPY --from=build /myapp/server-build /myapp/server-build
65 | COPY --from=build /myapp/build /myapp/build
66 | COPY --from=build /myapp/public /myapp/public
67 | COPY --from=build /myapp/package.json /myapp/package.json
68 | COPY --from=build /myapp/prisma /myapp/prisma
69 | COPY --from=build /myapp/app/components/ui/icons /myapp/app/components/ui/icons
70 |
71 | # prepare for litefs
72 | COPY --from=flyio/litefs:0.5.8 /usr/local/bin/litefs /usr/local/bin/litefs
73 | ADD other/litefs.yml /etc/litefs.yml
74 | RUN mkdir -p /data ${LITEFS_DIR}
75 |
76 | ADD . .
77 |
78 | CMD ["litefs", "mount"]
79 |
--------------------------------------------------------------------------------
/other/README.md:
--------------------------------------------------------------------------------
1 | # Other
2 |
3 | The "other" directory is where we put stuff that doesn't really have a place,
4 | but we don't want in the root of the project. In fact, we want to move as much
5 | stuff here from the root as possible. The only things that should stay in the
6 | root directory are those things that have to stay in the root for most editor
7 | and other tool integrations (like most configuration files sadly). Maybe one day
8 | we can convince tools to adopt a new `.config` directory in the future. Until
9 | then, we've got this `./other` directory to keep things cleaner.
10 |
--------------------------------------------------------------------------------
/other/build-server.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { fileURLToPath } from 'url'
3 | import esbuild from 'esbuild'
4 | import fsExtra from 'fs-extra'
5 | import { globSync } from 'glob'
6 |
7 | const pkg = fsExtra.readJsonSync(path.join(process.cwd(), 'package.json'))
8 |
9 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
10 | const here = (...s: Array) => path.join(__dirname, ...s)
11 | const globsafe = (s: string) => s.replace(/\\/g, '/')
12 |
13 | const allFiles = globSync(globsafe(here('../server/**/*.*')), {
14 | ignore: [
15 | 'server/dev-server.js', // for development only
16 | '**/tsconfig.json',
17 | '**/eslint*',
18 | '**/__tests__/**',
19 | ],
20 | })
21 |
22 | const entries = []
23 | for (const file of allFiles) {
24 | if (/\.(ts|js|tsx|jsx)$/.test(file)) {
25 | entries.push(file)
26 | } else {
27 | const dest = file.replace(here('../server'), here('../server-build'))
28 | fsExtra.ensureDirSync(path.parse(dest).dir)
29 | fsExtra.copySync(file, dest)
30 | console.log(`copied: ${file.replace(`${here('../server')}/`, '')}`)
31 | }
32 | }
33 |
34 | console.log()
35 | console.log('building...')
36 |
37 | esbuild
38 | .build({
39 | entryPoints: entries,
40 | outdir: here('../server-build'),
41 | target: [`node${pkg.engines.node}`],
42 | platform: 'node',
43 | sourcemap: true,
44 | format: 'esm',
45 | logLevel: 'info',
46 | })
47 | .catch((error: unknown) => {
48 | console.error(error)
49 | process.exit(1)
50 | })
51 |
--------------------------------------------------------------------------------
/other/litefs.yml:
--------------------------------------------------------------------------------
1 | # Documented example: https://github.com/superfly/litefs/blob/dec5a7353292068b830001bd2df4830e646f6a2f/cmd/litefs/etc/litefs.yml
2 | fuse:
3 | # Required. This is the mount directory that applications will
4 | # use to access their SQLite databases.
5 | dir: '${LITEFS_DIR}'
6 |
7 | data:
8 | # Path to internal data storage.
9 | dir: '/data/litefs'
10 |
11 | proxy:
12 | # matches the internal_port in fly.toml
13 | addr: ':${INTERNAL_PORT}'
14 | target: 'localhost:${PORT}'
15 | db: '${DATABASE_FILENAME}'
16 |
17 | # The lease section specifies how the cluster will be managed. We're using the
18 | # "consul" lease type so that our application can dynamically change the primary.
19 | #
20 | # These environment variables will be available in your Fly.io application.
21 | lease:
22 | type: 'consul'
23 | candidate: ${FLY_REGION == PRIMARY_REGION}
24 | promote: true
25 | advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202'
26 |
27 | consul:
28 | url: '${FLY_CONSUL_URL}'
29 | key: 'epic-stack-litefs/${FLY_APP_NAME}'
30 |
31 | exec:
32 | - cmd: node ./other/setup-swap.js
33 |
34 | - cmd: npx prisma migrate deploy
35 | if-candidate: true
36 |
37 | - cmd: npm start
38 |
--------------------------------------------------------------------------------
/other/sentry-create-release.js:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import { createRelease } from '@sentry/remix/scripts/createRelease.js'
3 | import { glob } from 'glob'
4 | import 'dotenv/config'
5 |
6 | const DEFAULT_URL_PREFIX = '#build/'
7 | const DEFAULT_BUILD_PATH = 'public/build'
8 |
9 | // exit with non-zero code if we have everything for Sentry
10 | if (
11 | process.env.SENTRY_DSN &&
12 | process.env.SENTRY_ORG &&
13 | process.env.SENTRY_PROJECT &&
14 | process.env.SENTRY_AUTH_TOKEN
15 | ) {
16 | createRelease({}, DEFAULT_URL_PREFIX, DEFAULT_BUILD_PATH)
17 | } else {
18 | console.log(
19 | 'Missing Sentry environment variables, skipping sourcemap upload.',
20 | )
21 | }
22 | const files = await glob(['./public/**/*.map', './build/**/*.map'])
23 | for (const file of files) {
24 | // remove file
25 | await fs.promises.unlink(file)
26 | }
27 |
--------------------------------------------------------------------------------
/other/setup-swap.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { writeFile } from 'node:fs/promises'
4 | import { $ } from 'execa'
5 |
6 | console.log('setting up swapfile...')
7 | await $`fallocate -l 512M /swapfile`
8 | await $`chmod 0600 /swapfile`
9 | await $`mkswap /swapfile`
10 | await writeFile('/proc/sys/vm/swappiness', '10')
11 | await $`swapon /swapfile`
12 | await writeFile('/proc/sys/vm/overcommit_memory', '1')
13 | console.log('swapfile setup complete')
14 |
--------------------------------------------------------------------------------
/other/sly/sly.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://sly-cli.fly.dev/registry/config.json",
3 | "libraries": [
4 | {
5 | "name": "@radix-ui/icons",
6 | "directory": "./other/svg-icons",
7 | "postinstall": ["npm", "run", "build:icons"],
8 | "transformers": ["transform-icon.ts"]
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/other/sly/transform-icon.ts:
--------------------------------------------------------------------------------
1 | import { type Meta } from '@sly-cli/sly'
2 |
3 | /**
4 | * @type {import('@sly-cli/sly/dist').Transformer}
5 | */
6 | export default function transformIcon(input: string, meta: Meta) {
7 | input = prependLicenseInfo(input, meta)
8 |
9 | return input
10 | }
11 |
12 | function prependLicenseInfo(input: string, meta: Meta): string {
13 | return [
14 | ``,
15 | ``,
16 | ``,
17 | input,
18 | ].join('\n')
19 | }
20 |
--------------------------------------------------------------------------------
/other/svg-icons/README.md:
--------------------------------------------------------------------------------
1 | # Icons
2 |
3 | These icons were downloaded from https://icons.radix-ui.com/ which is licensed
4 | under MIT: https://github.com/radix-ui/icons/blob/master/LICENSE
5 |
6 | It's important that you only add icons to this directory that the application
7 | actually needs as there's no "tree-shaking" for sprites. If you wish to manually
8 | split up your SVG sprite into multiple files, you'll need to update the
9 | `build-icons.ts` script to do that.
10 |
11 | Run `npm run build:icons` to update the sprite.
12 |
--------------------------------------------------------------------------------
/other/svg-icons/arrow-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/arrow-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/avatar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/camera.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/check.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/clock.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/cross-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/dots-horizontal.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/download.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/envelope-closed.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/exit.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/file-text.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/github-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/laptop.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/link-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/lock-closed.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/lock-open-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/magnifying-glass.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/moon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/pencil-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/pencil-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/plus.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/question-mark-circled.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/reset.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/sun.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/trash.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/update.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test'
2 | import 'dotenv/config'
3 |
4 | const PORT = process.env.PORT || '3000'
5 |
6 | export default defineConfig({
7 | testDir: './tests/e2e',
8 | timeout: 15 * 1000,
9 | expect: {
10 | timeout: 5 * 1000,
11 | },
12 | fullyParallel: true,
13 | forbidOnly: !!process.env.CI,
14 | retries: process.env.CI ? 2 : 0,
15 | workers: process.env.CI ? 1 : undefined,
16 | reporter: 'html',
17 | use: {
18 | baseURL: `http://localhost:${PORT}/`,
19 | trace: 'on-first-retry',
20 | },
21 |
22 | projects: [
23 | {
24 | name: 'chromium',
25 | use: {
26 | ...devices['Desktop Chrome'],
27 | },
28 | },
29 | ],
30 |
31 | webServer: {
32 | command: process.env.CI ? 'npm run start:mocks' : 'npm run dev',
33 | port: Number(PORT),
34 | reuseExistingServer: !process.env.CI,
35 | stdout: 'pipe',
36 | stderr: 'pipe',
37 | env: {
38 | PORT,
39 | },
40 | },
41 | })
42 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | 'tailwindcss/nesting': {},
4 | tailwindcss: {},
5 | autoprefixer: {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/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"
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicons/README.md:
--------------------------------------------------------------------------------
1 | # Favicon
2 |
3 | This directory has a few versions of icons to account for different devices. In
4 | some cases, we cannot reliably detect light/dark mode preference. Hence some of
5 | the icons in here should not have a transparent background. These icons are
6 | referenced in the `site.webmanifest` file.
7 |
8 | Note, there's also a `favicon.ico` in the root of `/public` which some older
9 | browsers will request automatically. This is a fallback for those browsers.
10 |
--------------------------------------------------------------------------------
/public/favicons/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/favicons/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/favicons/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/favicons/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/favicons/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/favicons/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/favicons/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/favicons/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicons/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/public/favicons/mask-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/laundry.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/img/laundry.jpeg
--------------------------------------------------------------------------------
/public/img/space-scene.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/img/space-scene.png
--------------------------------------------------------------------------------
/public/img/user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/public/img/user.png
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Epic Notes",
3 | "short_name": "Epic Notes",
4 | "start_url": "/",
5 | "icons": [
6 | {
7 | "src": "/favicons/android-chrome-192x192.png",
8 | "sizes": "192x192",
9 | "type": "image/png"
10 | },
11 | {
12 | "src": "/favicons/android-chrome-512x512.png",
13 | "sizes": "512x512",
14 | "type": "image/png"
15 | }
16 | ],
17 | "theme_color": "#A9ADC1",
18 | "background_color": "#1f2028",
19 | "display": "standalone"
20 | }
21 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | import { flatRoutes } from 'remix-flat-routes'
2 |
3 | /**
4 | * @type {import('@remix-run/dev').AppConfig}
5 | */
6 | export default {
7 | cacheDirectory: './node_modules/.cache/remix',
8 | ignoredRouteFiles: ['**/*'],
9 | serverModuleFormat: 'esm',
10 | serverPlatform: 'node',
11 | tailwind: true,
12 | postcss: true,
13 | watchPaths: ['./tailwind.config.ts'],
14 | routes: async defineRoutes => {
15 | return flatRoutes('routes', defineRoutes, {
16 | ignoredRouteFiles: [
17 | '.*',
18 | '**/*.css',
19 | '**/*.test.{js,jsx,ts,tsx}',
20 | '**/__*.*',
21 | ],
22 | })
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/server/dev-server.js:
--------------------------------------------------------------------------------
1 | import { execa } from 'execa'
2 |
3 | if (process.env.NODE_ENV === 'production') {
4 | await import('./index.js')
5 | } else {
6 | const command =
7 | 'tsx watch --clear-screen=false --ignore "app/**" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js'
8 | execa(command, {
9 | stdio: ['ignore', 'inherit', 'inherit'],
10 | shell: true,
11 | env: {
12 | FORCE_COLOR: true,
13 | MOCKS: true,
14 | ...process.env,
15 | },
16 | // https://github.com/sindresorhus/execa/issues/433
17 | windowsHide: false,
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from 'tailwindcss'
2 | import animatePlugin from 'tailwindcss-animate'
3 | import radixPlugin from 'tailwindcss-radix'
4 | import imageTextPreset from './app/tailwind-presets/image-text.ts'
5 | import { extendedTheme } from './app/utils/extended-theme.ts'
6 |
7 | export default {
8 | content: ['./app/**/*.{ts,tsx,jsx,js}'],
9 | darkMode: 'class',
10 | presets: [imageTextPreset],
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: '2rem',
15 | screens: {
16 | '2xl': '1400px',
17 | },
18 | },
19 | extend: extendedTheme,
20 | },
21 | plugins: [animatePlugin, radixPlugin],
22 | } satisfies Config
23 |
--------------------------------------------------------------------------------
/tests/db-utils.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import { faker } from '@faker-js/faker'
3 | import { type PrismaClient } from '@prisma/client'
4 | import bcrypt from 'bcryptjs'
5 | import { UniqueEnforcer } from 'enforce-unique'
6 |
7 | const uniqueUsernameEnforcer = new UniqueEnforcer()
8 |
9 | export function createUser() {
10 | const firstName = faker.person.firstName()
11 | const lastName = faker.person.lastName()
12 |
13 | const username = uniqueUsernameEnforcer
14 | .enforce(() => {
15 | return (
16 | faker.string.alphanumeric({ length: 2 }) +
17 | '_' +
18 | faker.internet.userName({
19 | firstName: firstName.toLowerCase(),
20 | lastName: lastName.toLowerCase(),
21 | })
22 | )
23 | })
24 | .slice(0, 20)
25 | .toLowerCase()
26 | .replace(/[^a-z0-9_]/g, '_')
27 | return {
28 | username,
29 | name: `${firstName} ${lastName}`,
30 | email: `${username}@example.com`,
31 | }
32 | }
33 |
34 | export function createPassword(password: string = faker.internet.password()) {
35 | return {
36 | hash: bcrypt.hashSync(password, 10),
37 | }
38 | }
39 |
40 | let noteImages: Array>> | undefined
41 | export async function getNoteImages() {
42 | if (noteImages) return noteImages
43 |
44 | noteImages = await Promise.all([
45 | img({
46 | altText: 'a nice country house',
47 | filepath: './tests/fixtures/images/notes/0.png',
48 | }),
49 | img({
50 | altText: 'a city scape',
51 | filepath: './tests/fixtures/images/notes/1.png',
52 | }),
53 | img({
54 | altText: 'a sunrise',
55 | filepath: './tests/fixtures/images/notes/2.png',
56 | }),
57 | img({
58 | altText: 'a group of friends',
59 | filepath: './tests/fixtures/images/notes/3.png',
60 | }),
61 | img({
62 | altText: 'friends being inclusive of someone who looks lonely',
63 | filepath: './tests/fixtures/images/notes/4.png',
64 | }),
65 | img({
66 | altText: 'an illustration of a hot air balloon',
67 | filepath: './tests/fixtures/images/notes/5.png',
68 | }),
69 | img({
70 | altText:
71 | 'an office full of laptops and other office equipment that look like it was abandoned in a rush out of the building in an emergency years ago.',
72 | filepath: './tests/fixtures/images/notes/6.png',
73 | }),
74 | img({
75 | altText: 'a rusty lock',
76 | filepath: './tests/fixtures/images/notes/7.png',
77 | }),
78 | img({
79 | altText: 'something very happy in nature',
80 | filepath: './tests/fixtures/images/notes/8.png',
81 | }),
82 | img({
83 | altText: `someone at the end of a cry session who's starting to feel a little better.`,
84 | filepath: './tests/fixtures/images/notes/9.png',
85 | }),
86 | ])
87 |
88 | return noteImages
89 | }
90 |
91 | let userImages: Array>> | undefined
92 | export async function getUserImages() {
93 | if (userImages) return userImages
94 |
95 | userImages = await Promise.all(
96 | Array.from({ length: 10 }, (_, index) =>
97 | img({ filepath: `./tests/fixtures/images/user/${index}.jpg` }),
98 | ),
99 | )
100 |
101 | return userImages
102 | }
103 |
104 | export async function img({
105 | altText,
106 | filepath,
107 | }: {
108 | altText?: string
109 | filepath: string
110 | }) {
111 | return {
112 | altText,
113 | contentType: filepath.endsWith('.png') ? 'image/png' : 'image/jpeg',
114 | blob: await fs.promises.readFile(filepath),
115 | }
116 | }
117 |
118 | export async function cleanupDb(prisma: PrismaClient) {
119 | const tables = await prisma.$queryRaw<
120 | { name: string }[]
121 | >`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_prisma_migrations';`
122 |
123 | await prisma.$transaction([
124 | // Disable FK constraints to avoid relation conflicts during deletion
125 | prisma.$executeRawUnsafe(`PRAGMA foreign_keys = OFF`),
126 | // Delete all rows from each table, preserving table structures
127 | ...tables.map(({ name }) =>
128 | prisma.$executeRawUnsafe(`DELETE from "${name}"`),
129 | ),
130 | prisma.$executeRawUnsafe(`PRAGMA foreign_keys = ON`),
131 | ])
132 | }
133 |
--------------------------------------------------------------------------------
/tests/e2e/2fa.test.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker'
2 | import { generateTOTP } from '#app/utils/totp.server.ts'
3 | import { expect, test } from '#tests/playwright-utils.ts'
4 |
5 | test('Users can add 2FA to their account and use it when logging in', async ({
6 | page,
7 | login,
8 | }) => {
9 | const password = faker.internet.password()
10 | const user = await login({ password })
11 | await page.goto('/settings/profile')
12 |
13 | await page.getByRole('link', { name: /enable 2fa/i }).click()
14 |
15 | await expect(page).toHaveURL(`/settings/profile/two-factor`)
16 | const main = page.getByRole('main')
17 | await main.getByRole('button', { name: /enable 2fa/i }).click()
18 | const otpUriString = await main
19 | .getByLabel(/One-Time Password URI/i)
20 | .innerText()
21 |
22 | const otpUri = new URL(otpUriString)
23 | const options = Object.fromEntries(otpUri.searchParams)
24 |
25 | await main
26 | .getByRole('textbox', { name: /code/i })
27 | .fill(generateTOTP(options).otp)
28 | await main.getByRole('button', { name: /submit/i }).click()
29 |
30 | await expect(main).toHaveText(/You have enabled two-factor authentication./i)
31 | await expect(main.getByRole('link', { name: /disable 2fa/i })).toBeVisible()
32 |
33 | await page.getByRole('link', { name: user.name ?? user.username }).click()
34 | await page.getByRole('button', { name: /logout/i }).click()
35 | await expect(page).toHaveURL(`/`)
36 |
37 | await page.goto('/login')
38 | await expect(page).toHaveURL(`/login`)
39 | await page.getByRole('textbox', { name: /username/i }).fill(user.username)
40 | await page.getByLabel(/^password$/i).fill(password)
41 | await page.getByRole('button', { name: /log in/i }).click()
42 |
43 | await page
44 | .getByRole('textbox', { name: /code/i })
45 | .fill(generateTOTP(options).otp)
46 |
47 | await page.getByRole('button', { name: /submit/i }).click()
48 |
49 | await expect(
50 | page.getByRole('link', { name: user.name ?? user.username }),
51 | ).toBeVisible()
52 | })
53 |
--------------------------------------------------------------------------------
/tests/e2e/error-boundary.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '#tests/playwright-utils.ts'
2 |
3 | test('Test root error boundary caught', async ({ page }) => {
4 | const pageUrl = '/does-not-exist'
5 | const res = await page.goto(pageUrl)
6 |
7 | expect(res?.status()).toBe(404)
8 | await expect(page.getByText(/We can't find this page/i)).toBeVisible()
9 | })
10 |
--------------------------------------------------------------------------------
/tests/fixtures/github/ghost.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/github/ghost.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/cute-koala.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/kody-notes/cute-koala.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-coder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/kody-notes/koala-coder.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-cuddle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/kody-notes/koala-cuddle.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-eating.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/kody-notes/koala-eating.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-mentor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/kody-notes/koala-mentor.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-soccer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/kody-notes/koala-soccer.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/mountain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/kody-notes/mountain.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/0.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/1.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/2.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/3.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/4.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/5.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/6.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/7.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/8.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/notes/9.png
--------------------------------------------------------------------------------
/tests/fixtures/images/user/0.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/0.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/1.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/2.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/3.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/4.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/5.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/6.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/7.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/7.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/8.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/9.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/9.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/README.md:
--------------------------------------------------------------------------------
1 | # User Images
2 |
3 | This is used when creating users with images. If you don't do that, feel free to
4 | delete this directory.
5 |
--------------------------------------------------------------------------------
/tests/fixtures/images/user/kody.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/simon-epic-tips/72690143d8fe5b361c956c36512f0b73663607ff/tests/fixtures/images/user/kody.png
--------------------------------------------------------------------------------
/tests/mocks/README.md:
--------------------------------------------------------------------------------
1 | # Mocks
2 |
3 | Use this to mock any third party HTTP resources that you don't have running
4 | locally and want to have mocked for local development as well as tests.
5 |
6 | Learn more about how to use this at [mswjs.io](https://mswjs.io/)
7 |
8 | For an extensive example, see the
9 | [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/index.ts)
10 |
--------------------------------------------------------------------------------
/tests/mocks/index.ts:
--------------------------------------------------------------------------------
1 | import closeWithGrace from 'close-with-grace'
2 | import { passthrough, http } from 'msw'
3 | import { setupServer } from 'msw/node'
4 | import { handlers as githubHandlers } from './github.ts'
5 | import { handlers as resendHandlers } from './resend.ts'
6 |
7 | const miscHandlers = [
8 | process.env.REMIX_DEV_ORIGIN
9 | ? http.post(`${process.env.REMIX_DEV_ORIGIN}ping`, passthrough)
10 | : null,
11 | ].filter(Boolean)
12 |
13 | export const server = setupServer(
14 | ...miscHandlers,
15 | ...resendHandlers,
16 | ...githubHandlers,
17 | )
18 |
19 | server.listen({ onUnhandledRequest: 'warn' })
20 |
21 | if (process.env.NODE_ENV !== 'test') {
22 | console.info('🔶 Mock server installed')
23 |
24 | closeWithGrace(() => {
25 | server.close()
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/tests/mocks/resend.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker'
2 | import { HttpResponse, http, type HttpHandler } from 'msw'
3 | import { requireHeader, writeEmail } from './utils.ts'
4 |
5 | const { json } = HttpResponse
6 |
7 | export const handlers: Array = [
8 | http.post(`https://api.resend.com/emails`, async ({ request }) => {
9 | requireHeader(request.headers, 'Authorization')
10 | const body = await request.json()
11 | console.info('🔶 mocked email contents:', body)
12 |
13 | const email = await writeEmail(body)
14 |
15 | return json({
16 | id: faker.string.uuid(),
17 | from: email.from,
18 | to: email.to,
19 | created_at: new Date().toISOString(),
20 | })
21 | }),
22 | ]
23 |
--------------------------------------------------------------------------------
/tests/mocks/utils.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import { fileURLToPath } from 'node:url'
3 | import fsExtra from 'fs-extra'
4 | import { z } from 'zod'
5 |
6 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
7 | const fixturesDirPath = path.join(__dirname, '..', 'fixtures')
8 |
9 | export async function readFixture(subdir: string, name: string) {
10 | return fsExtra.readJSON(path.join(fixturesDirPath, subdir, `${name}.json`))
11 | }
12 |
13 | export async function createFixture(
14 | subdir: string,
15 | name: string,
16 | data: unknown,
17 | ) {
18 | const dir = path.join(fixturesDirPath, subdir)
19 | await fsExtra.ensureDir(dir)
20 | return fsExtra.writeJSON(path.join(dir, `./${name}.json`), data)
21 | }
22 |
23 | export const EmailSchema = z.object({
24 | to: z.string(),
25 | from: z.string(),
26 | subject: z.string(),
27 | text: z.string(),
28 | html: z.string(),
29 | })
30 |
31 | export async function writeEmail(rawEmail: unknown) {
32 | const email = EmailSchema.parse(rawEmail)
33 | await createFixture('email', email.to, email)
34 | return email
35 | }
36 |
37 | export async function requireEmail(recipient: string) {
38 | const email = await readEmail(recipient)
39 | if (!email) throw new Error(`Email to ${recipient} not found`)
40 | return email
41 | }
42 |
43 | export async function readEmail(recipient: string) {
44 | try {
45 | const email = await readFixture('email', recipient)
46 | return EmailSchema.parse(email)
47 | } catch (error) {
48 | console.error(`Error reading email`, error)
49 | return null
50 | }
51 | }
52 |
53 | export function requireHeader(headers: Headers, header: string) {
54 | if (!headers.has(header)) {
55 | const headersString = JSON.stringify(
56 | Object.fromEntries(headers.entries()),
57 | null,
58 | 2,
59 | )
60 | throw new Error(
61 | `Header "${header}" required, but not found in ${headersString}`,
62 | )
63 | }
64 | return headers.get(header)
65 | }
66 |
--------------------------------------------------------------------------------
/tests/playwright-utils.ts:
--------------------------------------------------------------------------------
1 | import { test as base } from '@playwright/test'
2 | import { type User as UserModel } from '@prisma/client'
3 | import * as setCookieParser from 'set-cookie-parser'
4 | import {
5 | getPasswordHash,
6 | getSessionExpirationDate,
7 | sessionKey,
8 | } from '#app/utils/auth.server.ts'
9 | import { prisma } from '#app/utils/db.server.ts'
10 | import { authSessionStorage } from '#app/utils/session.server.ts'
11 | import { createUser } from './db-utils.ts'
12 |
13 | export * from './db-utils.ts'
14 |
15 | type GetOrInsertUserOptions = {
16 | id?: string
17 | username?: UserModel['username']
18 | password?: string
19 | email?: UserModel['email']
20 | }
21 |
22 | type User = {
23 | id: string
24 | email: string
25 | username: string
26 | name: string | null
27 | }
28 |
29 | async function getOrInsertUser({
30 | id,
31 | username,
32 | password,
33 | email,
34 | }: GetOrInsertUserOptions = {}): Promise {
35 | const select = { id: true, email: true, username: true, name: true }
36 | if (id) {
37 | return await prisma.user.findUniqueOrThrow({
38 | select,
39 | where: { id: id },
40 | })
41 | } else {
42 | const userData = createUser()
43 | username ??= userData.username
44 | password ??= userData.username
45 | email ??= userData.email
46 | return await prisma.user.create({
47 | select,
48 | data: {
49 | ...userData,
50 | email,
51 | username,
52 | roles: { connect: { name: 'user' } },
53 | password: { create: { hash: await getPasswordHash(password) } },
54 | },
55 | })
56 | }
57 | }
58 |
59 | export const test = base.extend<{
60 | insertNewUser(options?: GetOrInsertUserOptions): Promise
61 | login(options?: GetOrInsertUserOptions): Promise
62 | }>({
63 | insertNewUser: async ({}, use) => {
64 | let userId: string | undefined = undefined
65 | await use(async options => {
66 | const user = await getOrInsertUser(options)
67 | userId = user.id
68 | return user
69 | })
70 | await prisma.user.delete({ where: { id: userId } }).catch(() => {})
71 | },
72 | login: async ({ page }, use) => {
73 | let userId: string | undefined = undefined
74 | await use(async options => {
75 | const user = await getOrInsertUser(options)
76 | userId = user.id
77 | const session = await prisma.session.create({
78 | data: {
79 | expirationDate: getSessionExpirationDate(),
80 | userId: user.id,
81 | },
82 | select: { id: true },
83 | })
84 |
85 | const authSession = await authSessionStorage.getSession()
86 | authSession.set(sessionKey, session.id)
87 | const cookieConfig = setCookieParser.parseString(
88 | await authSessionStorage.commitSession(authSession),
89 | ) as any
90 | await page
91 | .context()
92 | .addCookies([{ ...cookieConfig, domain: 'localhost' }])
93 | return user
94 | })
95 | await prisma.user.deleteMany({ where: { id: userId } })
96 | },
97 | })
98 | export const { expect } = test
99 |
100 | /**
101 | * This allows you to wait for something (like an email to be available).
102 | *
103 | * It calls the callback every 50ms until it returns a value (and does not throw
104 | * an error). After the timeout, it will throw the last error that was thrown or
105 | * throw the error message provided as a fallback
106 | */
107 | export async function waitFor(
108 | cb: () => ReturnValue | Promise,
109 | {
110 | errorMessage,
111 | timeout = 5000,
112 | }: { errorMessage?: string; timeout?: number } = {},
113 | ) {
114 | const endTime = Date.now() + timeout
115 | let lastError: unknown = new Error(errorMessage)
116 | while (Date.now() < endTime) {
117 | try {
118 | const response = await cb()
119 | if (response) return response
120 | } catch (e: unknown) {
121 | lastError = e
122 | }
123 | await new Promise(r => setTimeout(r, 100))
124 | }
125 | throw lastError
126 | }
127 |
--------------------------------------------------------------------------------
/tests/setup/db-setup.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import fsExtra from 'fs-extra'
3 | import { afterAll, afterEach, beforeAll } from 'vitest'
4 | import { cleanupDb } from '#tests/db-utils.ts'
5 | import { BASE_DATABASE_PATH } from './global-setup.ts'
6 |
7 | const databaseFile = `./tests/prisma/data.${process.env.VITEST_POOL_ID || 0}.db`
8 | const databasePath = path.join(process.cwd(), databaseFile)
9 | process.env.DATABASE_URL = `file:${databasePath}`
10 |
11 | beforeAll(async () => {
12 | await fsExtra.copyFile(BASE_DATABASE_PATH, databasePath)
13 | })
14 |
15 | // we *must* use dynamic imports here so the process.env.DATABASE_URL is set
16 | // before prisma is imported and initialized
17 | afterEach(async () => {
18 | const { prisma } = await import('#app/utils/db.server.ts')
19 | await cleanupDb(prisma)
20 | })
21 |
22 | afterAll(async () => {
23 | const { prisma } = await import('#app/utils/db.server.ts')
24 | await prisma.$disconnect()
25 | await fsExtra.remove(databasePath)
26 | })
27 |
--------------------------------------------------------------------------------
/tests/setup/global-setup.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import { execaCommand } from 'execa'
3 | import fsExtra from 'fs-extra'
4 |
5 | export const BASE_DATABASE_PATH = path.join(
6 | process.cwd(),
7 | `./tests/prisma/base.db`,
8 | )
9 |
10 | export async function setup() {
11 | const databaseExists = await fsExtra.pathExists(BASE_DATABASE_PATH)
12 | if (databaseExists) return
13 |
14 | await execaCommand(
15 | 'prisma migrate reset --force --skip-seed --skip-generate',
16 | {
17 | stdio: 'inherit',
18 | env: {
19 | ...process.env,
20 | DATABASE_URL: `file:${BASE_DATABASE_PATH}`,
21 | },
22 | },
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/tests/setup/setup-test-env.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import './db-setup.ts'
3 | import '#app/utils/env.server.ts'
4 | // we need these to be imported first 👆
5 |
6 | import { installGlobals } from '@remix-run/node'
7 | import { cleanup } from '@testing-library/react'
8 | import { afterEach, beforeEach, vi, type SpyInstance } from 'vitest'
9 | import { server } from '#tests/mocks/index.ts'
10 | import './custom-matchers.ts'
11 |
12 | installGlobals()
13 |
14 | afterEach(() => server.resetHandlers())
15 | afterEach(() => cleanup())
16 |
17 | export let consoleError: SpyInstance>
18 |
19 | beforeEach(() => {
20 | const originalConsoleError = console.error
21 | consoleError = vi.spyOn(console, 'error')
22 | consoleError.mockImplementation(
23 | (...args: Parameters) => {
24 | originalConsoleError(...args)
25 | throw new Error(
26 | 'Console error was called. Call consoleError.mockImplementation(() => {}) if this is expected.',
27 | )
28 | },
29 | )
30 | })
31 |
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import * as setCookieParser from 'set-cookie-parser'
2 | import { sessionKey } from '#app/utils/auth.server.ts'
3 | import { authSessionStorage } from '#app/utils/session.server.ts'
4 |
5 | export const BASE_URL = 'https://www.epicstack.dev'
6 |
7 | export function convertSetCookieToCookie(setCookie: string) {
8 | const parsedCookie = setCookieParser.parseString(setCookie)
9 | return new URLSearchParams({
10 | [parsedCookie.name]: parsedCookie.value,
11 | }).toString()
12 | }
13 |
14 | export async function getSessionSetCookieHeader(
15 | session: { id: string },
16 | existingCookie?: string,
17 | ) {
18 | const authSession = await authSessionStorage.getSession(existingCookie)
19 | authSession.set(sessionKey, session.id)
20 | const setCookieHeader = await authSessionStorage.commitSession(authSession)
21 | return setCookieHeader
22 | }
23 |
24 | export async function getSessionCookieHeader(
25 | session: { id: string },
26 | existingCookie?: string,
27 | ) {
28 | const setCookieHeader = await getSessionSetCookieHeader(
29 | session,
30 | existingCookie,
31 | )
32 | return convertSetCookieToCookie(setCookieHeader)
33 | }
34 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "module": "ES2022",
9 | "target": "ES2022",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "strict": true,
13 | "noImplicitAny": true,
14 | "allowJs": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "paths": {
17 | "#*": ["./*"],
18 | "@/icon-name": [
19 | "./app/components/ui/icons/name.d.ts",
20 | "./types/icon-name.d.ts"
21 | ]
22 | },
23 | "skipLibCheck": true,
24 | "allowImportingTsExtensions": true,
25 | "noEmit": true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/types/deps.d.ts:
--------------------------------------------------------------------------------
1 | // This module should contain type definitions for modules which do not have
2 | // their own type definitions and are not available on DefinitelyTyped.
3 |
4 | declare module 'tailwindcss-animate' {
5 | declare const _default: {
6 | handler: () => void
7 | }
8 | export = _default
9 | }
10 |
--------------------------------------------------------------------------------
/types/icon-name.d.ts:
--------------------------------------------------------------------------------
1 | // This file is a fallback until you run npm run build:icons
2 |
3 | export type IconName = string
4 |
--------------------------------------------------------------------------------
/types/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/types/reset.d.ts:
--------------------------------------------------------------------------------
1 | // Do not add any other lines of code to this file!
2 | import '@total-typescript/ts-reset/dom'
3 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import react from '@vitejs/plugin-react'
4 | import { defineConfig } from 'vite'
5 |
6 | export default defineConfig({
7 | plugins: [react()],
8 | css: { postcss: { plugins: [] } },
9 | test: {
10 | include: ['./app/**/*.test.{ts,tsx}'],
11 | setupFiles: ['./tests/setup/setup-test-env.ts'],
12 | globalSetup: ['./tests/setup/global-setup.ts'],
13 | restoreMocks: true,
14 | coverage: {
15 | include: ['app/**/*.{ts,tsx}'],
16 | all: true,
17 | },
18 | },
19 | })
20 |
--------------------------------------------------------------------------------