├── .env.example ├── .eslintrc.cjs ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── deploy.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── README.md ├── app ├── components │ ├── confetti.tsx │ ├── 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 │ │ ├── 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 │ ├── _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 │ ├── me.tsx │ ├── resources+ │ │ ├── download-user-data.tsx │ │ ├── healthcheck.tsx │ │ ├── note-images.$imageId.tsx │ │ └── user-images.$imageId.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 │ └── users+ │ │ ├── $username.test.tsx │ │ ├── $username.tsx │ │ ├── $username_+ │ │ ├── __note-editor.tsx │ │ ├── notes.$noteId.tsx │ │ ├── notes.$noteId_.edit.tsx │ │ ├── notes.index.tsx │ │ ├── notes.new.tsx │ │ └── notes.tsx │ │ └── index.tsx ├── styles │ ├── font.css │ └── tailwind.css └── utils │ ├── auth.server.ts │ ├── cache.server.ts │ ├── client-hints.tsx │ ├── confetti.server.ts │ ├── connections.server.ts │ ├── connections.tsx │ ├── csrf.server.ts │ ├── db.server.ts │ ├── email.server.ts │ ├── env.server.ts │ ├── extended-theme.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 ├── 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Epic Stack with Confetti 2 | 3 | We use confetti-react and follow similar patterns to the toast notifications. 4 | 5 | Check the commit history for details on the changes necessary to add confetti. 6 | -------------------------------------------------------------------------------- /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 { 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 |
autoSubmit && handleFormChange(e.currentTarget)} 36 | > 37 |
38 | 41 | 50 |
51 |
52 | 58 | 59 | Search 60 | 61 |
62 |
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 { 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 | | '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/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 |