├── .env.example ├── .eslintrc.cjs ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── deploy.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── app ├── components │ ├── admin-dropdown.tsx │ ├── confetti.tsx │ ├── error-boundary.tsx │ ├── floating-toolbar.tsx │ ├── forms.tsx │ ├── menu.tsx │ ├── progress-bar.tsx │ ├── search-bar.tsx │ ├── spacer.tsx │ ├── toaster.tsx │ └── ui │ │ ├── README.md │ │ ├── button.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── icon.tsx │ │ ├── icons │ │ ├── README.md │ │ ├── name.d.ts │ │ └── sprite.svg │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── status-button.tsx │ │ ├── textarea.tsx │ │ └── tooltip.tsx ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx ├── routes │ ├── $.tsx │ ├── _auth+ │ │ ├── auth.$provider.callback.test.ts │ │ ├── auth.$provider.callback.ts │ │ ├── auth.$provider.ts │ │ ├── forgot-password.tsx │ │ ├── login.tsx │ │ ├── logout.tsx │ │ ├── onboarding.tsx │ │ ├── onboarding_.$provider.tsx │ │ ├── reset-password.tsx │ │ ├── signup.tsx │ │ └── verify.tsx │ ├── _index.tsx │ ├── _marketing+ │ │ ├── about.tsx │ │ ├── index.tsx │ │ ├── logos │ │ │ ├── docker.svg │ │ │ ├── eslint.svg │ │ │ ├── faker.svg │ │ │ ├── fly.svg │ │ │ ├── github.svg │ │ │ ├── logos.ts │ │ │ ├── msw.svg │ │ │ ├── playwright.svg │ │ │ ├── prettier.svg │ │ │ ├── prisma.svg │ │ │ ├── radix.svg │ │ │ ├── react-email.svg │ │ │ ├── remix.svg │ │ │ ├── resend.svg │ │ │ ├── sentry.svg │ │ │ ├── shadcn-ui.svg │ │ │ ├── sqlite.svg │ │ │ ├── stars.jpg │ │ │ ├── tailwind.svg │ │ │ ├── testing-library.png │ │ │ ├── typescript.svg │ │ │ ├── vitest.svg │ │ │ └── zod.svg │ │ ├── privacy.tsx │ │ ├── support.tsx │ │ └── tos.tsx │ ├── _seo+ │ │ ├── robots[.]txt.ts │ │ └── sitemap[.]xml.ts │ ├── admin+ │ │ ├── cache.tsx │ │ ├── cache_.lru.$cacheKey.ts │ │ ├── cache_.sqlite.$cacheKey.ts │ │ └── cache_.sqlite.tsx │ ├── decision_+ │ │ ├── $slug.tsx │ │ └── index.tsx │ ├── me.tsx │ ├── resources+ │ │ └── healthcheck.tsx │ ├── settings+ │ │ ├── profile.change-email.tsx │ │ ├── profile.connections.tsx │ │ ├── profile.index.tsx │ │ ├── profile.password.tsx │ │ ├── profile.password_.create.tsx │ │ ├── profile.photo.tsx │ │ ├── profile.tsx │ │ ├── profile.two-factor.disable.tsx │ │ ├── profile.two-factor.index.tsx │ │ ├── profile.two-factor.tsx │ │ └── profile.two-factor.verify.tsx │ └── topic_+ │ │ ├── $slug.tsx │ │ └── index.tsx ├── styles │ ├── font.css │ ├── prose.css │ └── tailwind.css └── utils │ ├── auth.server.ts │ ├── cache.server.ts │ ├── client-hints.tsx │ ├── compile-mdx.server.ts │ ├── confetti.server.ts │ ├── connections.server.ts │ ├── connections.tsx │ ├── csrf.server.ts │ ├── db.server.ts │ ├── email.server.ts │ ├── env.server.ts │ ├── extended-theme.ts │ ├── github.server.ts │ ├── honeypot.server.ts │ ├── litefs.server.ts │ ├── mdx.tsx │ ├── 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 │ ├── search.server.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 ├── content ├── decisions │ ├── README.md │ ├── change-email │ │ └── index.mdx │ ├── client-pref-cookies │ │ └── index.mdx │ ├── components │ │ └── index.mdx │ ├── content-security-policy │ │ └── index.mdx │ ├── csrf │ │ └── index.mdx │ ├── cuid │ │ └── index.mdx │ ├── email-code │ │ └── index.mdx │ ├── email-service │ │ └── index.mdx │ ├── github-actions │ │ └── index.mdx │ ├── github-auth │ │ └── index.mdx │ ├── honeypot │ │ └── index.mdx │ ├── icons │ │ └── index.mdx │ ├── images │ │ └── index.mdx │ ├── imports │ │ └── index.mdx │ ├── memory-swap │ │ └── index.mdx │ ├── monitoring │ │ └── index.mdx │ ├── native-esm │ │ └── index.mdx │ ├── node-version │ │ └── index.mdx │ ├── path-aliases │ │ └── index.mdx │ ├── permissions-rbac │ │ └── index.mdx │ ├── rate-limiting │ │ └── index.mdx │ ├── region-selection │ │ └── index.mdx │ ├── remix-auth │ │ └── index.mdx │ ├── report-only-csp │ │ └── index.mdx │ ├── resend-email │ │ └── index.mdx │ ├── route-based-dialogs │ │ └── index.mdx │ ├── sessions │ │ └── index.mdx │ ├── sitemaps │ │ └── index.mdx │ ├── source-maps │ │ └── index.mdx │ ├── sqlite │ │ └── index.mdx │ ├── template │ │ └── index.mdx │ ├── toasts │ │ └── index.mdx │ ├── totp │ │ └── index.mdx │ └── typescript-only │ │ └── index.mdx └── docs │ ├── apis │ └── index.mdx │ ├── authentication │ └── index.mdx │ ├── caching │ └── index.mdx │ ├── categories.ts │ ├── client-hints │ └── index.mdx │ ├── community │ └── index.mdx │ ├── database │ └── index.mdx │ ├── deployment │ └── index.mdx │ ├── email │ └── index.mdx │ ├── examples │ └── index.mdx │ ├── features │ └── index.mdx │ ├── fonts │ └── index.mdx │ ├── getting-started │ └── index.mdx │ ├── guiding-principles │ └── index.mdx │ ├── icons │ └── index.mdx │ ├── landing │ └── index.mdx │ ├── managing-updates │ └── index.mdx │ ├── memory │ └── index.mdx │ ├── monitoring │ └── index.mdx │ ├── permissions │ └── index.mdx │ ├── redirects │ └── index.mdx │ ├── routing │ └── index.mdx │ ├── secrets │ └── index.mdx │ ├── security │ └── index.mdx │ ├── seo │ └── index.mdx │ ├── server-timing │ └── index.mdx │ ├── testing │ └── index.mdx │ ├── timezone │ └── index.mdx │ ├── toasts │ └── index.mdx │ └── troubleshooting │ └── index.mdx ├── 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 │ ├── chevron-down.svg │ ├── clock.svg │ ├── cross-1.svg │ ├── dots-horizontal.svg │ ├── download.svg │ ├── envelope-closed.svg │ ├── epic-logo-light.svg │ ├── epic-logo.svg │ ├── epic-stack-light.svg │ ├── epic-stack.svg │ ├── exit.svg │ ├── file-text.svg │ ├── github-logo.svg │ ├── hamburger-menu.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 ├── fonts │ └── nunito-sans │ │ ├── nunito-sans-v12-latin_latin-ext-200.woff │ │ ├── nunito-sans-v12-latin_latin-ext-200.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-200italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-200italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-300.woff │ │ ├── nunito-sans-v12-latin_latin-ext-300.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-300italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-300italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-600.woff │ │ ├── nunito-sans-v12-latin_latin-ext-600.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-600italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-600italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-700.woff │ │ ├── nunito-sans-v12-latin_latin-ext-700.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-700italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-700italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-800.woff │ │ ├── nunito-sans-v12-latin_latin-ext-800.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-800italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-800italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-900.woff │ │ ├── nunito-sans-v12-latin_latin-ext-900.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-900italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-900italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-regular.woff │ │ └── nunito-sans-v12-latin_latin-ext-regular.woff2 ├── img │ └── 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 | -------------------------------------------------------------------------------- /.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/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 | "workbench.colorCustomizations": { 20 | "activityBar.activeBackground": "#892fdb", 21 | "activityBar.background": "#892fdb", 22 | "activityBar.foreground": "#e7e7e7", 23 | "activityBar.inactiveForeground": "#e7e7e799", 24 | "activityBarBadge.background": "#d98426", 25 | "activityBarBadge.foreground": "#15202b", 26 | "commandCenter.border": "#e7e7e799", 27 | "sash.hoverBorder": "#892fdb", 28 | "statusBar.background": "#6f20b7", 29 | "statusBar.foreground": "#e7e7e7", 30 | "statusBarItem.hoverBackground": "#892fdb", 31 | "statusBarItem.remoteBackground": "#6f20b7", 32 | "statusBarItem.remoteForeground": "#e7e7e7", 33 | "titleBar.activeBackground": "#6f20b7", 34 | "titleBar.activeForeground": "#e7e7e7", 35 | "titleBar.inactiveBackground": "#6f20b799", 36 | "titleBar.inactiveForeground": "#e7e7e799" 37 | }, 38 | "peacock.color": "#6f20b7" 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

The Epic Stack 🚀

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 | [![The Epic Stack](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/246885449-1b00286c-aa3d-44b2-9ef2-04f694eb3592.png)](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 | [![Epic Stack Talk slide showing Flynn Rider with knives, the text "I've been around and I've got opinions" and Kent speaking in the corner](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/277818553-47158e68-4efc-43ae-a477-9d1670d4217d.png)](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/confetti.tsx: -------------------------------------------------------------------------------- 1 | import { Index as ConfettiShower } from 'confetti-react' 2 | import { ClientOnly } from 'remix-utils/client-only' 3 | 4 | export function Confetti({ id }: { id?: string | null }) { 5 | if (!id) return null 6 | 7 | return ( 8 | 9 | {() => ( 10 | 18 | )} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ErrorResponse, 3 | isRouteErrorResponse, 4 | useParams, 5 | useRouteError, 6 | } from '@remix-run/react' 7 | import { getErrorMessage } from '#app/utils/misc.tsx' 8 | 9 | type StatusHandler = (info: { 10 | error: ErrorResponse 11 | params: Record 12 | }) => JSX.Element | null 13 | 14 | export function GeneralErrorBoundary({ 15 | defaultStatusHandler = ({ error }) => ( 16 |

17 | {error.status} {error.data} 18 |

19 | ), 20 | statusHandlers, 21 | unexpectedErrorHandler = error =>

{getErrorMessage(error)}

, 22 | }: { 23 | defaultStatusHandler?: StatusHandler 24 | statusHandlers?: Record 25 | unexpectedErrorHandler?: (error: unknown) => JSX.Element | null 26 | }) { 27 | const error = useRouteError() 28 | const params = useParams() 29 | 30 | if (typeof document !== 'undefined') { 31 | console.error(error) 32 | } 33 | 34 | return ( 35 |
36 | {isRouteErrorResponse(error) 37 | ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ 38 | error, 39 | params, 40 | }) 41 | : unexpectedErrorHandler(error)} 42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /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 } from '#app/utils/misc.tsx' 4 | import { Input } from './ui/input.tsx' 5 | import { Label } from './ui/label.tsx' 6 | 7 | export function SearchBar({ 8 | status, 9 | autoFocus = false, 10 | autoSubmit = false, 11 | }: { 12 | status: 'idle' | 'pending' | 'success' | 'error' 13 | autoFocus?: boolean 14 | autoSubmit?: boolean 15 | }) { 16 | const id = useId() 17 | const [searchParams] = useSearchParams() 18 | const submit = useSubmit() 19 | // TODO: use the pending state to show a spinner 20 | // const isSubmitting = useIsPending({ 21 | // formMethod: 'GET', 22 | // formAction: '/topic', 23 | // }) 24 | 25 | const handleFormChange = useDebounce((form: HTMLFormElement) => { 26 | submit(form) 27 | }, 400) 28 | 29 | return ( 30 |
autoSubmit && handleFormChange(e.currentTarget)} 35 | > 36 |
37 | 40 | 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /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 { Toaster, toast as showToast } from 'sonner' 3 | import { type Toast } from '#app/utils/toast.server.ts' 4 | 5 | export function EpicToaster({ toast }: { toast?: Toast | null }) { 6 | return ( 7 | <> 8 | 9 | {toast ? : null} 10 | 11 | ) 12 | } 13 | 14 | function ShowToast({ toast }: { toast: Toast }) { 15 | const { id, type, title, description } = toast 16 | useEffect(() => { 17 | setTimeout(() => { 18 | showToast[type](title, { id, description }) 19 | }, 0) 20 | }, [description, id, title, type]) 21 | return null 22 | } 23 | -------------------------------------------------------------------------------- /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 | | 'chevron-down' 10 | | 'clock' 11 | | 'cross-1' 12 | | 'dots-horizontal' 13 | | 'download' 14 | | 'envelope-closed' 15 | | 'epic-logo-light' 16 | | 'epic-logo' 17 | | 'epic-stack-light' 18 | | 'epic-stack' 19 | | 'exit' 20 | | 'file-text' 21 | | 'github-logo' 22 | | 'hamburger-menu' 23 | | 'laptop' 24 | | 'link-2' 25 | | 'lock-closed' 26 | | 'lock-open-1' 27 | | 'magnifying-glass' 28 | | 'moon' 29 | | 'pencil-1' 30 | | 'pencil-2' 31 | | 'plus' 32 | | 'question-mark-circled' 33 | | 'reset' 34 | | 'sun' 35 | | 'trash' 36 | | 'update' 37 | -------------------------------------------------------------------------------- /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/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 | 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 |